rollout-2.5.0/0000755000004100000410000000000013735633650013261 5ustar www-datawww-datarollout-2.5.0/.travis.yml0000644000004100000410000000040013735633650015364 0ustar www-datawww-datalanguage: ruby cache: bundler sudo: false services: - redis-server rvm: - 2.6 - 2.5 - 2.4 - 2.3 - 2.2 - 2.1 - 2.0 env: - USE_REAL_REDIS=true gemfile: - gemfiles/redis_3.gemfile - gemfiles/redis_4.gemfile script: - bundle exec rspec rollout-2.5.0/.rspec0000644000004100000410000000001013735633650014365 0ustar www-datawww-data--color rollout-2.5.0/README.md0000644000004100000410000001144613735633650014546 0ustar www-datawww-data# rollout Fast feature flags based on Redis. [![Gem Version](https://badge.fury.io/rb/rollout.svg)](https://badge.fury.io/rb/rollout) [![CircleCI](https://circleci.com/gh/fetlife/rollout.svg?style=svg)](https://circleci.com/gh/fetlife/rollout) [![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/FetLife/rollout) [![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](https://codeclimate.com/github/FetLife/rollout/coverage) ## Install it ```bash gem install rollout ``` ## How it works Initialize a rollout object. I assign it to a global var. ```ruby require 'redis' $redis = Redis.new $rollout = Rollout.new($redis) ``` or even simpler ```ruby require 'redis' $rollout = Rollout.new(Redis.current) # Will use REDIS_URL env var or default redis url ``` Update data specific to a feature: ```ruby $rollout.set_feature_data(:chat, description: 'foo', release_date: 'bar', whatever: 'baz') ``` Check whether a feature is active for a particular user: ```ruby $rollout.active?(:chat, User.first) # => true/false ``` Check whether a feature is active globally: ```ruby $rollout.active?(:chat) ``` You can activate features using a number of different mechanisms. ## Groups Rollout ships with one group by default: "all", which does exactly what it sounds like. You can activate the all group for the chat feature like this: ```ruby $rollout.activate_group(:chat, :all) ``` You might also want to define your own groups. We have one for our caretakers: ```ruby $rollout.define_group(:caretakers) do |user| user.caretaker? end ``` You can activate multiple groups per feature. Deactivate groups like this: ```ruby $rollout.deactivate_group(:chat, :all) ``` Groups need to be defined every time your app starts. The logic is not persisted anywhere. ## Specific Users You might want to let a specific user into a beta test or something. If that user isn't part of an existing group, you can let them in specifically: ```ruby $rollout.activate_user(:chat, @user) ``` Deactivate them like this: ```ruby $rollout.deactivate_user(:chat, @user) ``` ## User Percentages If you're rolling out a new feature, you might want to test the waters by slowly enabling it for a percentage of your users. ```ruby $rollout.activate_percentage(:chat, 20) ``` The algorithm for determining which users get let in is this: ```ruby CRC32(user.id) < (2**32 - 1) / 100.0 * percentage ``` So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users would remain in as the percentage increases. Deactivate all percentages like this: ```ruby $rollout.deactivate_percentage(:chat) ``` _Note that activating a feature for 100% of users will also make it active "globally". That is when calling Rollout#active? without a user object._ 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) ``` When on `randomize_percentage` will make sure that 50% of users for feature A are selected independently from users for feature B. ## Global actions While groups can come in handy, the actual global setter for a feature does not require a group to be passed. ```ruby $rollout.activate(:chat) ``` In that case you can check the global availability of a feature using the following ```ruby $rollout.active?(:chat) ``` And if something is wrong you can set a feature off for everybody using Deactivate everybody at once: ```ruby $rollout.deactivate(:chat) ``` For many of our features, we keep track of error rates using redis, and deactivate them automatically when a threshold is reached to prevent service failures from cascading. See https://github.com/jamesgolick/degrade for the failure detection code. ## Namespacing Rollout separates its keys from other keys in the data store using the "feature" keyspace. If you're using redis, you can namespace keys further to support multiple environments by using the [redis-namespace](https://github.com/resque/redis-namespace) gem. ```ruby $ns = Redis::Namespace.new(Rails.env, redis: $redis) $rollout = Rollout.new($ns) $rollout.activate_group(:chat, :all) ``` This example would use the "development:feature:chat:groups" key. ## Frontend / UI * [Rollout-Dashboard](https://github.com/fiverr/rollout_dashboard/) ## Implementations in other languages * Python: https://github.com/asenchi/proclaim * PHP: https://github.com/opensoft/rollout * Clojure: https://github.com/yeller/shoutout * Perl: https://metacpan.org/pod/Toggle ## Contributors * James Golick - Creator - https://github.com/jamesgolick * Eric Rafaloff - Maintainer - https://github.com/EricR ## Copyright Copyright (c) 2010-InfinityAndBeyond BitLove, Inc. See LICENSE for details. rollout-2.5.0/.circleci/0000755000004100000410000000000013735633650015114 5ustar www-datawww-datarollout-2.5.0/.circleci/config.yml0000644000004100000410000000365513735633650017115 0ustar www-datawww-dataversion: 2.1 workflows: main: jobs: - ruby27 - ruby26 - ruby25 - ruby24 - ruby23 executors: ruby27: docker: - image: circleci/ruby:2.7 - image: circleci/redis:alpine ruby26: docker: - image: circleci/ruby:2.6 - image: circleci/redis:alpine ruby25: docker: - image: circleci/ruby:2.5 - image: circleci/redis:alpine ruby24: docker: - image: circleci/ruby:2.4 - image: circleci/redis:alpine ruby23: docker: - image: circleci/ruby:2.3 - image: circleci/redis:alpine commands: test: steps: - restore_cache: keys: - bundler-{{ checksum "Gemfile.lock" }} - run: name: Bundle Install command: bundle check --path vendor/bundle || bundle install - save_cache: key: bundler-{{ checksum "Gemfile.lock" }} paths: - vendor/bundle - run: name: Run rspec command: | bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml jobs: ruby27: executor: ruby27 steps: - checkout - test - run: name: Report Test Coverage command: | wget https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 -O cc-test-reporter chmod +x cc-test-reporter ./cc-test-reporter format-coverage -t simplecov -o coverage/codeclimate.json coverage/.resultset.json ./cc-test-reporter upload-coverage -i coverage/codeclimate.json - store_test_results: path: test_results ruby26: executor: ruby26 steps: - checkout - test ruby25: executor: ruby25 steps: - checkout - test ruby24: executor: ruby24 steps: - checkout - test ruby23: executor: ruby23 steps: - checkout - test rollout-2.5.0/spec/0000755000004100000410000000000013735633650014213 5ustar www-datawww-datarollout-2.5.0/spec/rollout/0000755000004100000410000000000013735633650015713 5ustar www-datawww-datarollout-2.5.0/spec/rollout/logging_spec.rb0000644000004100000410000001023413735633650020700 0ustar www-datawww-datarequire 'spec_helper' RSpec.describe 'Rollout::Logging' do let(:rollout) { Rollout.new(Redis.current, 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 context 'logging data changes' do it 'logs changes' do expect(rollout.logging.last_event(feature)).to be_nil rollout.set_feature_data(feature, description: "foo") 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 end context 'no logging' do let(:logging) { nil } it 'doesnt even respond to logging' do expect(rollout).not_to respond_to :logging end 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 }) 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 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) 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 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] 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).to be_nil end end end rollout-2.5.0/spec/rollout_spec.rb0000644000004100000410000005234413735633650017262 0ustar www-datawww-datarequire "spec_helper" RSpec.describe "Rollout" do before do @rollout = Rollout.new(Redis.current) end 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 "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 "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 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 '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 describe "Rollout::Feature" do describe "#add_user" do it "ids a user using id_user_by" do user = double("User", email: "test@test.com") feature = Rollout::Feature.new(:chat, nil, id_user_by: :email) feature.add_user(user) expect(user).to have_received :email end end describe "#initialize" do describe "when string does not exist" do it 'clears feature attributes when string is not given' do feature = Rollout::Feature.new(:chat) expect(feature.groups).to be_empty expect(feature.users).to be_empty expect(feature.percentage).to eq 0 expect(feature.data).to eq({}) end it 'clears feature attributes when string is nil' do feature = Rollout::Feature.new(:chat, nil) expect(feature.groups).to be_empty expect(feature.users).to be_empty expect(feature.percentage).to eq 0 expect(feature.data).to eq({}) end it 'clears feature attributes when string is empty string' do feature = Rollout::Feature.new(:chat, "") expect(feature.groups).to be_empty expect(feature.users).to be_empty expect(feature.percentage).to eq 0 expect(feature.data).to eq({}) end describe "when there is no data" do it 'sets @data to empty hash' do feature = Rollout::Feature.new(:chat, "0||") expect(feature.data).to eq({}) end it 'sets @data to empty hash' do feature = Rollout::Feature.new(:chat, "||| ") expect(feature.data).to eq({}) end end end end end rollout-2.5.0/spec/spec_helper.rb0000644000004100000410000000101013735633650017021 0ustar www-datawww-data# frozen_string_literal: true require 'simplecov' SimpleCov.start require 'bundler/setup' require 'redis' require 'rollout' Redis.current = Redis.new( host: ENV.fetch('REDIS_HOST', '127.0.0.1'), port: ENV.fetch('REDIS_PORT', '6379'), db: ENV.fetch('REDIS_DB', '7'), ) 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.current.flushdb } end rollout-2.5.0/.rubocop.yml0000644000004100000410000000025213735633650015532 0ustar www-datawww-dataAllCops: Exclude: - 'spec/**/*' Metrics/LineLength: Max: 120 Metrics/MethodLength: Max: 20 Style/TrailingCommaInArguments: EnforcedStyleForMultiline: comma rollout-2.5.0/.gitignore0000644000004100000410000000026613735633650015255 0ustar www-datawww-data## MAC OS .DS_Store ## TEXTMATE *.tmproj tmtags ## EMACS *~ \#* .\#* ## VIM *.swp ## PROJECT::GENERAL coverage rdoc pkg *.gem gemfiles/*.lock .rspec_status ## PROJECT::SPECIFIC rollout-2.5.0/LICENSE0000644000004100000410000000206313735633650014267 0ustar www-datawww-dataCopyright (c) 2010-InfinityAndBeyond BitLove, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. rollout-2.5.0/Rakefile0000644000004100000410000000016513735633650014730 0ustar www-datawww-data# frozen_string_literal: true require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) task default: :spec rollout-2.5.0/lib/0000755000004100000410000000000013735633650014027 5ustar www-datawww-datarollout-2.5.0/lib/rollout/0000755000004100000410000000000013735633650015527 5ustar www-datawww-datarollout-2.5.0/lib/rollout/version.rb0000644000004100000410000000010513735633650017535 0ustar www-datawww-data# frozen_string_literal: true class Rollout VERSION = '2.5.0' end rollout-2.5.0/lib/rollout/logging.rb0000644000004100000410000001223213735633650017502 0ustar www-datawww-dataclass 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) rollout.define_singleton_method(:logging) do logger end end 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 @data = data @context = context @created_at = created_at end def timestamp (@created_at.to_f * 1_000_000).to_i end def serialize JSON.dump( feature: @feature, name: @name, data: @data, context: @context, ) end def ==(other) feature == other.feature \ && name == other.name \ && data == other.data \ && created_at == other.created_at end end class Logger def initialize(storage: nil, history_length: 50, global: false) @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 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 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 end def global_events @storage .zrange(global_events_storage_key, 0, -1, with_scores: true) .map { |v| Event.from_raw(*v) } .reverse end def delete(feature_name) storage_key = events_storage_key(feature_name) @storage.del(storage_key) end def update(before, after) before_hash = before.to_hash before_hash.delete(:data).each do |k, v| before_hash["data.#{k}"] = v end after_hash = after.to_hash after_hash.delete(:data).each do |k, v| after_hash["data.#{k}"] = v end keys = before_hash.keys | after_hash.keys change = { before: {}, after: {} } changed_count = 0 keys.each do |key| next if before_hash[key] == after_hash[key] change[:before][key] = before_hash[key] change[:after][key] = after_hash[key] changed_count += 1 end return if changed_count == 0 event = 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 WITHOUT_THREAD_KEY = :rollout_logging_disabled def with_context(context) raise ArgumentError, "context must be a Hash" unless context.is_a?(Hash) raise ArgumentError, "block is required" unless block_given? Thread.current[CONTEXT_THREAD_KEY] = context yield ensure Thread.current[CONTEXT_THREAD_KEY] = nil end def current_context Thread.current[CONTEXT_THREAD_KEY] || {} end def without Thread.current[WITHOUT_THREAD_KEY] = true yield ensure Thread.current[WITHOUT_THREAD_KEY] = nil 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 rollout-2.5.0/lib/rollout/feature.rb0000644000004100000410000000537513735633650017521 0ustar www-datawww-data# frozen_string_literal: true class Rollout class Feature attr_accessor :groups, :users, :percentage, :data attr_reader :name, :options def initialize(name, string = nil, opts = {}) @options = opts @name = name if string raw_percentage, raw_users, raw_groups, raw_data = string.split('|', 4) @percentage = raw_percentage.to_f @users = users_from_string(raw_users) @groups = groups_from_string(raw_groups) @data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data) else clear end end def serialize "#{@percentage}|#{@users.to_a.join(',')}|#{@groups.to_a.join(',')}|#{serialize_data}" end def add_user(user) id = user_id(user) @users << id unless @users.include?(id) end def remove_user(user) @users.delete(user_id(user)) end def add_group(group) @groups << group.to_sym unless @groups.include?(group.to_sym) end def remove_group(group) @groups.delete(group.to_sym) end def clear @groups = groups_from_string('') @users = users_from_string('') @percentage = 0 @data = {} end def active?(rollout, user) if user id = user_id(user) user_in_percentage?(id) || user_in_active_users?(id) || user_in_active_group?(user, rollout) else @percentage == 100 end end def user_in_active_users?(user) @users.include?(user_id(user)) end def to_hash { percentage: @percentage, groups: @groups, users: @users, data: @data, } end private def user_id(user) if user.is_a?(Integer) || user.is_a?(String) user.to_s else user.send(id_user_by).to_s end end def id_user_by @options[:id_user_by] || :id end def user_in_percentage?(user) Zlib.crc32(user_id_for_percentage(user)) < RAND_BASE * @percentage end def user_id_for_percentage(user) if @options[:randomize_percentage] user_id(user).to_s + @name.to_s else user_id(user) end end def user_in_active_group?(user, rollout) @groups.any? do |g| rollout.active_in_group?(g, user) end end def serialize_data return '' unless @data.is_a? Hash @data.to_json end def users_from_string(raw_users) users = (raw_users || '').split(',').map(&:to_s) if @options[:use_sets] users.to_set else users end end def groups_from_string(raw_groups) groups = (raw_groups || '').split(',').map(&:to_sym) if @options[:use_sets] groups.to_set else groups end end end end rollout-2.5.0/lib/rollout.rb0000644000004100000410000001076213735633650016062 0ustar www-datawww-data# frozen_string_literal: true require 'rollout/feature' require 'rollout/logging' require 'rollout/version' require 'zlib' require 'set' require 'json' require 'observer' class Rollout include Observable RAND_BASE = (2**32 - 1) / 100.0 attr_reader :options, :storage def initialize(storage, opts = {}) @storage = storage @options = opts @groups = { all: ->(_user) { true } } extend(Logging) if opts[:logging] end def groups @groups.keys end def activate(feature) with_feature(feature) do |f| f.percentage = 100 end end def deactivate(feature) with_feature(feature, &:clear) 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)) if respond_to?(:logging) logging.delete(feature) end end def set(feature, desired_state) with_feature(feature) do |f| if desired_state f.percentage = 100 else f.clear end end end def activate_group(feature, group) with_feature(feature) do |f| f.add_group(group) end end def deactivate_group(feature, group) with_feature(feature) do |f| f.remove_group(group) end end def activate_user(feature, user) with_feature(feature) do |f| f.add_user(user) end end def deactivate_user(feature, user) with_feature(feature) do |f| f.remove_user(user) end end def activate_users(feature, users) with_feature(feature) do |f| users.each { |user| f.add_user(user) } end end def deactivate_users(feature, users) with_feature(feature) do |f| users.each { |user| f.remove_user(user) } end end def set_users(feature, users) with_feature(feature) do |f| f.users = [] users.each { |user| f.add_user(user) } end end def define_group(group, &block) @groups[group.to_sym] = block end def active?(feature, user = nil) feature = get(feature) feature.active?(self, user) end def user_in_active_users?(feature, user = nil) feature = get(feature) feature.user_in_active_users?(user) end def inactive?(feature, user = nil) !active?(feature, user) end def activate_percentage(feature, percentage) with_feature(feature) do |f| f.percentage = percentage end end def deactivate_percentage(feature) with_feature(feature) do |f| f.percentage = 0 end end def active_in_group?(group, user) f = @groups[group.to_sym] f&.call(user) end def get(feature) string = @storage.get(key(feature)) Feature.new(feature, string, @options) end def set_feature_data(feature, data) with_feature(feature) do |f| f.data.merge!(data) if data.is_a? Hash end end def clear_feature_data(feature) with_feature(feature) do |f| f.data = {} end end def multi_get(*features) return [] if features.empty? feature_keys = features.map { |feature| key(feature) } @storage.mget(*feature_keys).map.with_index { |string, index| Feature.new(features[index], string, @options) } end def features (@storage.get(features_key) || '').split(',').map(&:to_sym) end def feature_states(user = nil) multi_get(*features).each_with_object({}) do |f, hash| hash[f.name] = f.active?(self, user) end end def active_features(user = nil) multi_get(*features).select do |f| f.active?(self, user) end.map(&:name) end def clear! features.each do |feature| with_feature(feature, &:clear) @storage.del(key(feature)) end @storage.del(features_key) 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 end def with_feature(feature) f = get(feature) if count_observers > 0 before = Marshal.load(Marshal.dump(f)) yield(f) save(f) changed notify_observers(:update, before, f) else yield(f) save(f) end end private def key(name) "feature:#{name}" end def features_key 'feature:__features__' end def save(feature) @storage.set(key(feature.name), feature.serialize) @storage.set(features_key, (features | [feature.name.to_sym]).join(',')) end end rollout-2.5.0/Gemfile0000644000004100000410000000010613735633650014551 0ustar www-datawww-data# frozen_string_literal: true source 'https://rubygems.org' gemspec rollout-2.5.0/rollout.gemspec0000644000004100000410000000216413735633650016331 0ustar www-datawww-data# frozen_string_literal: true $LOAD_PATH.push File.expand_path('lib', __dir__) require 'rollout/version' Gem::Specification.new do |spec| spec.name = 'rollout' 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.homepage = 'https://github.com/FetLife/rollout' spec.license = 'MIT' spec.files = `git ls-files`.split("\n") 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'] spec.required_ruby_version = '>= 2.3' spec.add_dependency 'redis', '~> 4.0' spec.add_development_dependency 'bundler', '>= 1.17' spec.add_development_dependency 'pry' spec.add_development_dependency 'rspec', '~> 3.0' spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4' spec.add_development_dependency 'rubocop', '~> 0.71' spec.add_development_dependency 'simplecov', '0.17' end