diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..70c8672b8a6177e63c312c0b887bdd3ab37c8584 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,95 @@ +version: 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 diff --git a/.document b/.document deleted file mode 100644 index ecf3673194b8b6963488dabc93d5f16fea93c5e9..0000000000000000000000000000000000000000 --- a/.document +++ /dev/null @@ -1,5 +0,0 @@ -README.rdoc -lib/**/*.rb -bin/* -features/**/*.feature -LICENSE diff --git a/.gitignore b/.gitignore index 50290e0f2bf3e53f1282fbdebcd5dedbe11f690e..98b35f2a79e7c656f426896a073f891f5b658921 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,6 @@ rdoc pkg *.gem gemfiles/*.lock +.rspec_status ## PROJECT::SPECIFIC diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000000000000000000000000000000000000..f04fbba79f068a3404663118f64384bcea40f6df --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,11 @@ +AllCops: + Exclude: + - 'spec/**/*' + +Metrics/LineLength: + Max: 120 +Metrics/MethodLength: + Max: 20 + +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma diff --git a/.travis.yml b/.travis.yml index 7b96ef55f62d8a3596c282294e88537776e40e37..aff3ccd869edee250694ce277b180a67ae7bcfc5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,13 +4,13 @@ sudo: false services: - redis-server rvm: - - 2.4.1 - - 2.3.1 + - 2.6 + - 2.5 + - 2.4 + - 2.3 - 2.2 - 2.1 - - 2.0.0 - - 1.9.3 - - jruby-19mode + - 2.0 env: - USE_REAL_REDIS=true gemfile: diff --git a/Appraisals b/Appraisals deleted file mode 100644 index da230180a7411086a103aba330c7d5f34763a0d7..0000000000000000000000000000000000000000 --- a/Appraisals +++ /dev/null @@ -1,7 +0,0 @@ -appraise "redis-3" do - gem "redis", "~> 3.3.2" -end - -appraise "redis-4" do - gem "redis", "4.0.0.rc1" -end diff --git a/Gemfile b/Gemfile index b4e2a20bb6069d33479542fc863e7e36810e0f01..7f4f5e950d1572e1ce5607ca237375aa2e30d662 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ -source "https://rubygems.org" +# frozen_string_literal: true + +source 'https://rubygems.org' gemspec diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index df919eaa823e253e8b4abef591cd99378215453e..0000000000000000000000000000000000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,56 +0,0 @@ -PATH - remote: . - specs: - rollout (2.4.3) - -GEM - remote: https://rubygems.org/ - specs: - appraisal (2.2.0) - bundler - rake - thor (>= 0.14.0) - codeclimate-test-reporter (1.0.8) - simplecov (<= 0.13) - diff-lcs (1.3) - docile (1.1.5) - fakeredis (0.6.0) - redis (~> 3.2) - json (2.1.0) - rake (12.0.0) - redis (3.3.3) - rspec (3.6.0) - rspec-core (~> 3.6.0) - rspec-expectations (~> 3.6.0) - rspec-mocks (~> 3.6.0) - rspec-core (3.6.0) - rspec-support (~> 3.6.0) - rspec-expectations (3.6.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.6.0) - rspec-mocks (3.6.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.6.0) - rspec-support (3.6.0) - simplecov (0.13.0) - docile (~> 1.1.0) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.1) - thor (0.19.4) - -PLATFORMS - ruby - -DEPENDENCIES - appraisal - bundler (>= 1.0.0) - codeclimate-test-reporter - fakeredis - redis - rollout! - rspec - simplecov - -BUNDLED WITH - 1.15.1 diff --git a/README.md b/README.md index 85db04025470aa8aa7b8c3c19dfd66341df11a0b..8f71a971ee4a290a51fc64bdcde704867302ab92 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ Fast feature flags based on Redis. -[](https://travis-ci.org/fetlife/rollout) -[](https://codeclimate.com/github/fetlife/rollout) -[](https://codeclimate.com/github/fetlife/rollout/coverage) -[](https://gemnasium.com/fetlife/rollout) +[](https://badge.fury.io/rb/rollout) +[](https://circleci.com/gh/fetlife/rollout) +[](https://codeclimate.com/github/FetLife/rollout) +[](https://codeclimate.com/github/FetLife/rollout/coverage) ## Install it @@ -20,10 +20,18 @@ Initialize a rollout object. I assign it to a global var. ```ruby require 'redis' -$redis = Redis.new +$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 @@ -71,6 +79,9 @@ Deactivate groups like this: $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 @@ -98,7 +109,7 @@ $rollout.activate_percentage(:chat, 20) The algorithm for determining which users get let in is this: ```ruby -CRC32(user.id) % 100_000 < percentage * 1_000 +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 @@ -176,6 +187,7 @@ This example would use the "development:feature:chat:groups" key. * 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 diff --git a/Rakefile b/Rakefile index 5f101654a7187da48e803e6103cdc83cdacd3a03..cffdd09be3bb6db7406fe4692a06d80407999ef5 100644 --- a/Rakefile +++ b/Rakefile @@ -1,9 +1,7 @@ -begin - require "rspec/core/rake_task" +# frozen_string_literal: true - RSpec::Core::RakeTask.new(:spec) +require 'rspec/core/rake_task' - task default: :spec -rescue LoadError - # no rspec available -end +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/gemfiles/redis_3.gemfile b/gemfiles/redis_3.gemfile deleted file mode 100644 index b087aa826cdee9df9c53d89dc7031758384a1998..0000000000000000000000000000000000000000 --- a/gemfiles/redis_3.gemfile +++ /dev/null @@ -1,13 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -if RUBY_VERSION >= '2.0' - gem "json" -else - gem "json", "~> 1.8.3" -end - -gem "redis", "~> 3.3.2" - -gemspec :path => "../" diff --git a/gemfiles/redis_4.gemfile b/gemfiles/redis_4.gemfile deleted file mode 100644 index c2370626cc72ce5ff1bfb66610932d41058819d6..0000000000000000000000000000000000000000 --- a/gemfiles/redis_4.gemfile +++ /dev/null @@ -1,13 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -if RUBY_VERSION >= '2.0' - gem "json" -else - gem "json", "~> 1.8.3" -end - -gem "redis", "4.0.0.rc1" - -gemspec :path => "../" diff --git a/lib/rollout.rb b/lib/rollout.rb index fa6b3fbadd25f17ed08768d1a9d52f79f9cb3b8f..a8d7b2bc581064494d962b44ce3cff7ecb89cc0f 100644 --- a/lib/rollout.rb +++ b/lib/rollout.rb @@ -1,141 +1,30 @@ -require "rollout/version" -require "zlib" -require "set" -require "json" +# frozen_string_literal: true -class Rollout - RAND_BASE = (2**32 - 1) / 100.0 - - 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 - } - 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 +require 'rollout/feature' +require 'rollout/logging' +require 'rollout/version' +require 'zlib' +require 'set' +require 'json' +require 'observer' - 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 +class Rollout + include Observable - def users_from_string(raw_users) - users = (raw_users || "").split(",").map(&:to_s) - if @options[:use_sets] - users.to_set - else - users - end - end + RAND_BASE = (2**32 - 1) / 100.0 - 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 + attr_reader :options, :storage def initialize(storage, opts = {}) @storage = storage @options = opts - @groups = { all: lambda { |user| true } } + @groups = { all: ->(_user) { true } } + + extend(Logging) if opts[:logging] + end + + def groups + @groups.keys end def activate(feature) @@ -145,16 +34,18 @@ class Rollout end def deactivate(feature) - with_feature(feature) do |f| - f.clear - end + with_feature(feature, &:clear) end def delete(feature) - features = (@storage.get(features_key) || "").split(",") + features = (@storage.get(features_key) || '').split(',') features.delete(feature.to_s) - @storage.set(features_key, features.join(",")) + @storage.set(features_key, features.join(',')) @storage.del(key(feature)) + + if respond_to?(:logging) + logging.delete(feature) + end end def set(feature, desired_state) @@ -193,20 +84,20 @@ class Rollout def activate_users(feature, users) with_feature(feature) do |f| - users.each{|user| f.add_user(user)} + 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)} + 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)} + users.each { |user| f.add_user(user) } end end @@ -242,7 +133,7 @@ class Rollout def active_in_group?(group, user) f = @groups[group.to_sym] - f && f.call(user) + f&.call(user) end def get(feature) @@ -263,29 +154,31 @@ class Rollout end def multi_get(*features) - feature_keys = features.map{ |feature| key(feature) } + 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) + (@storage.get(features_key) || '').split(',').map(&:to_sym) end def feature_states(user = nil) - features.each_with_object({}) do |f, hash| - hash[f] = active?(f, user) + multi_get(*features).each_with_object({}) do |f, hash| + hash[f.name] = f.active?(self, user) end end def active_features(user = nil) - features.select do |f| - active?(f, user) - end + multi_get(*features).select do |f| + f.active?(self, user) + end.map(&:name) end def clear! features.each do |feature| - with_feature(feature) { |f| f.clear } + with_feature(feature, &:clear) @storage.del(key(feature)) end @@ -293,7 +186,28 @@ class Rollout end def exists?(feature) - @storage.exists(key(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 @@ -303,17 +217,11 @@ class Rollout end def features_key - "feature:__features__" - end - - def with_feature(feature) - f = get(feature) - yield(f) - save(f) + 'feature:__features__' end def save(feature) @storage.set(key(feature.name), feature.serialize) - @storage.set(features_key, (features | [feature.name.to_sym]).join(",")) + @storage.set(features_key, (features | [feature.name.to_sym]).join(',')) end end diff --git a/lib/rollout/feature.rb b/lib/rollout/feature.rb new file mode 100644 index 0000000000000000000000000000000000000000..a39a555a284cb31ca3d4cd54c18bba386f0e3dda --- /dev/null +++ b/lib/rollout/feature.rb @@ -0,0 +1,131 @@ +# 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 diff --git a/lib/rollout/logging.rb b/lib/rollout/logging.rb new file mode 100644 index 0000000000000000000000000000000000000000..a8fa7570406bfeed1c4a7cb87ede6c61c470acd6 --- /dev/null +++ b/lib/rollout/logging.rb @@ -0,0 +1,198 @@ +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) + 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 diff --git a/lib/rollout/version.rb b/lib/rollout/version.rb index 632a1900cfa14a307e2bfb7d03ba94c6f4e2b9b6..695b2ab64cb9fadb2dfb33225cc9208f3c50c0de 100644 --- a/lib/rollout/version.rb +++ b/lib/rollout/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Rollout - VERSION = "2.4.3" + VERSION = '2.5.0' end diff --git a/rollout.gemspec b/rollout.gemspec index 19d2eccc1734fc520d741722a517bfe6feb84797..0a8526ee8fd61d8c0b4ea3e19d50d2ddc956f229 100644 --- a/rollout.gemspec +++ b/rollout.gemspec @@ -1,27 +1,31 @@ -# -*- encoding: utf-8 -*- -$:.push File.expand_path("../lib", __FILE__) -require "rollout/version" +# frozen_string_literal: true -Gem::Specification.new do |s| - s.name = "rollout" - s.version = Rollout::VERSION - s.authors = ["James Golick"] - s.email = ["jamesgolick@gmail.com"] - s.description = "Feature flippers with redis." - s.summary = "Feature flippers with redis." - s.homepage = "https://github.com/FetLife/rollout" - s.license = "MIT" +$LOAD_PATH.push File.expand_path('lib', __dir__) +require 'rollout/version' - s.files = `git ls-files`.split("\n") - s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } - s.require_paths = ["lib"] +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' - s.add_development_dependency "rspec" - s.add_development_dependency "appraisal" - s.add_development_dependency "bundler", ">= 1.0.0" - s.add_development_dependency "redis" - s.add_development_dependency "fakeredis" - s.add_development_dependency "simplecov" - s.add_development_dependency "codeclimate-test-reporter" + 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 diff --git a/spec/rollout/logging_spec.rb b/spec/rollout/logging_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9e5c6915469803cf58edf348af9eb1afdcfaf4cf --- /dev/null +++ b/spec/rollout/logging_spec.rb @@ -0,0 +1,143 @@ +require '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 + diff --git a/spec/rollout_spec.rb b/spec/rollout_spec.rb index 73d5fee4ef12bce1f8913db670cd21f6dc8a0ca4..7d3f4be15bc0ee8314ceaabfbaff385de3c17868 100644 --- a/spec/rollout_spec.rb +++ b/spec/rollout_spec.rb @@ -2,8 +2,7 @@ require "spec_helper" RSpec.describe "Rollout" do before do - @redis = Redis.new - @rollout = Rollout.new(@redis) + @rollout = Rollout.new(Redis.current) end describe "when a group is activated" do @@ -430,7 +429,8 @@ RSpec.describe "Rollout" do expect(feature.to_hash).to eq( groups: [:caretakers, :greeters], percentage: 10, - users: %w(42) + users: %w(42), + data: {}, ) feature = @rollout.get(:signup) @@ -450,7 +450,8 @@ RSpec.describe "Rollout" do expect(feature.to_hash).to eq( groups: [:caretakers, :greeters].to_set, percentage: 10, - users: %w(42).to_set + users: %w(42).to_set, + data: {}, ) feature = @rollout.get(:signup) @@ -474,7 +475,8 @@ RSpec.describe "Rollout" do expect(@rollout.get(feature).to_hash).to eq( percentage: 0, users: [], - groups: [] + groups: [], + data: {}, ) end end @@ -486,7 +488,8 @@ RSpec.describe "Rollout" do expect(@rollout.get(feature).to_hash).to eq( percentage: 0, users: Set.new, - groups: Set.new + groups: Set.new, + data: {}, ) end end @@ -605,6 +608,12 @@ RSpec.describe "Rollout" do 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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a2b2d2d97314ee7fda473b26cd4406c758208a8e..e6b5821344a06c35213254e739558a69a9270b45 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,18 +1,27 @@ -$LOAD_PATH.unshift(File.dirname(__FILE__)) -$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) +# frozen_string_literal: true -require "simplecov" -require "rspec" -require ENV["USE_REAL_REDIS"] == "true" ? "redis" : "fakeredis" +require 'simplecov' -SimpleCov.start do - formatter SimpleCov::Formatter::MultiFormatter.new([ - SimpleCov::Formatter::HTMLFormatter, - ]) -end +SimpleCov.start + +require 'bundler/setup' +require 'redis' +require 'rollout' -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.before { Redis.new.flushdb } + 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