arturo 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +141 -91
  4. data/app/controllers/arturo/features_controller.rb +3 -3
  5. data/app/helpers/arturo/features_helper.rb +1 -0
  6. data/app/views/arturo/features/_feature.html.erb +1 -0
  7. data/app/views/arturo/features/_form.html.erb +1 -0
  8. data/app/views/arturo/features/edit.html.erb +1 -0
  9. data/app/views/arturo/features/forbidden.html.erb +1 -0
  10. data/app/views/arturo/features/index.html.erb +1 -0
  11. data/app/views/arturo/features/new.html.erb +1 -0
  12. data/app/views/arturo/features/show.html.erb +1 -0
  13. data/config/routes.rb +1 -0
  14. data/lib/arturo/controller_filters.rb +2 -1
  15. data/lib/arturo/engine.rb +6 -2
  16. data/lib/arturo/feature.rb +41 -38
  17. data/lib/arturo/feature_availability.rb +2 -1
  18. data/lib/arturo/feature_caching.rb +103 -10
  19. data/lib/arturo/feature_factories.rb +3 -2
  20. data/lib/arturo/feature_management.rb +1 -0
  21. data/lib/arturo/feature_params_support.rb +1 -0
  22. data/lib/arturo/middleware.rb +1 -0
  23. data/lib/arturo/no_such_feature.rb +1 -0
  24. data/lib/arturo/null_logger.rb +8 -0
  25. data/lib/arturo/special_handling.rb +6 -6
  26. data/lib/arturo/test_support.rb +3 -2
  27. data/lib/arturo/version.rb +4 -0
  28. data/lib/arturo.rb +12 -2
  29. data/lib/generators/arturo/assets_generator.rb +1 -0
  30. data/lib/generators/arturo/feature_model_generator.rb +14 -0
  31. data/lib/generators/arturo/initializer_generator.rb +1 -0
  32. data/lib/generators/arturo/migration_generator.rb +10 -10
  33. data/lib/generators/arturo/routes_generator.rb +1 -0
  34. data/lib/generators/arturo/templates/feature.rb +9 -0
  35. data/lib/generators/arturo/templates/initializer.rb +1 -0
  36. data/lib/generators/arturo/templates/migration.erb +17 -0
  37. metadata +50 -118
  38. data/lib/generators/arturo/templates/migration.rb +0 -17
@@ -1,41 +1,49 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_record'
3
+ require 'active_support'
2
4
 
3
- # a stub
4
- # possible TODO: remove and and refactor into an acts_as_feature mixin
5
5
  module Arturo
6
- class Feature < ::ActiveRecord::Base
7
-
6
+ module FeatureMethods
7
+ extend ActiveSupport::Concern
8
8
  include Arturo::SpecialHandling
9
9
 
10
- Arturo::Feature::SYMBOL_REGEX = /^[a-zA-z][a-zA-Z0-9_]*$/
10
+ SYMBOL_REGEX = /^[a-zA-z][a-zA-Z0-9_]*$/
11
11
  DEFAULT_ATTRIBUTES = { :deployment_percentage => 0 }.with_indifferent_access
12
12
 
13
- attr_accessible :symbol, :deployment_percentage if ActiveRecord::VERSION::MAJOR < 4
14
- attr_readonly :symbol
15
-
16
- validates_presence_of :symbol, :deployment_percentage
17
- validates_uniqueness_of :symbol, :allow_blank => true
18
- validates_numericality_of :deployment_percentage,
19
- :only_integer => true,
20
- :allow_blank => true,
21
- :greater_than_or_equal_to => 0,
22
- :less_than_or_equal_to => 100
23
-
24
- # Looks up a feature by symbol. Also accepts a Feature as input.
25
- # @param [Symbol, Arturo::Feature] feature_or_name a Feature or the Symbol of a Feature
26
- # @return [Arturo::Feature, nil] the Feature if found, else Arturo::NoSuchFeature
27
- def self.to_feature(feature_or_symbol)
28
- return feature_or_symbol if feature_or_symbol.kind_of?(self)
29
- symbol = feature_or_symbol.to_sym.to_s
30
- self.where(:symbol => symbol).first || Arturo::NoSuchFeature.new(symbol)
13
+ included do
14
+ attr_readonly :symbol
15
+
16
+ validates_presence_of :symbol, :deployment_percentage
17
+ validates_uniqueness_of :symbol, :allow_blank => true, :case_sensitive => false
18
+ validates_numericality_of :deployment_percentage,
19
+ :only_integer => true,
20
+ :allow_blank => true,
21
+ :greater_than_or_equal_to => 0,
22
+ :less_than_or_equal_to => 100
31
23
  end
32
24
 
33
- # Looks up a feature by symbol. Also accepts a Feature as input.
34
- # @param [Symbol, Arturo::Feature] feature_or_name a Feature or the Symbol of a Feature
35
- # @return [Arturo::Feature, nil] the Feature if found, else nil
36
- def self.find_feature(feature_or_symbol)
37
- feature = to_feature(feature_or_symbol)
38
- feature.is_a?(Arturo::NoSuchFeature) ? nil : feature
25
+ class_methods do
26
+ # Looks up a feature by symbol. Also accepts a Feature as input.
27
+ # @param [Symbol, Arturo::Feature] feature_or_symbol a Feature or the Symbol of a Feature
28
+ # @return [Arturo::Feature, Arturo::NoSuchFeature] the Feature if found, else Arturo::NoSuchFeature
29
+ def to_feature(feature_or_symbol)
30
+ return feature_or_symbol if feature_or_symbol.kind_of?(self)
31
+
32
+ symbol = feature_or_symbol.to_sym.to_s
33
+ self.where(:symbol => symbol).first || Arturo::NoSuchFeature.new(symbol)
34
+ end
35
+
36
+ # Looks up a feature by symbol. Also accepts a Feature as input.
37
+ # @param [Symbol, Arturo::Feature] feature_or_symbol a Feature or the Symbol of a Feature
38
+ # @return [Arturo::Feature, nil] the Feature if found, else nil
39
+ def find_feature(feature_or_symbol)
40
+ feature = to_feature(feature_or_symbol)
41
+ feature.is_a?(Arturo::NoSuchFeature) ? nil : feature
42
+ end
43
+
44
+ def last_updated_at
45
+ maximum(:updated_at)
46
+ end
39
47
  end
40
48
 
41
49
  # Create a new Feature
@@ -54,11 +62,12 @@ module Arturo
54
62
  return false if feature_recipient.nil?
55
63
  return false if blacklisted?(feature_recipient)
56
64
  return true if whitelisted?(feature_recipient)
57
- passes_threshold?(feature_recipient)
65
+ passes_threshold?(feature_recipient, deployment_percentage || 0)
58
66
  end
59
67
 
60
68
  def name
61
69
  return I18n.translate("arturo.feature.nameless") if symbol.blank?
70
+
62
71
  I18n.translate("arturo.feature.#{symbol}", :default => symbol.to_s.titleize)
63
72
  end
64
73
 
@@ -74,14 +83,8 @@ module Arturo
74
83
  "<Arturo::Feature #{name}, deployed to #{deployment_percentage}%>"
75
84
  end
76
85
 
77
- def self.last_updated_at
78
- maximum(:updated_at)
79
- end
80
-
81
- protected
82
-
83
- def passes_threshold?(feature_recipient)
84
- threshold = self.deployment_percentage || 0
86
+ # made public so as to allow for thresholds stored outside of the model
87
+ def passes_threshold?(feature_recipient, threshold)
85
88
  return true if threshold == 100
86
89
  return false if threshold == 0 || !feature_recipient.id
87
90
  (((feature_recipient.id + (self.id || 1) + 17) * 13) % 100) < threshold
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
 
3
4
  # A mixin that provides #feature_enabled? and #if_feature_enabled
@@ -24,7 +25,7 @@ module Arturo
24
25
  end
25
26
 
26
27
  # By default, returns current_user.
27
- #
28
+ #
28
29
  # If you would like to change this implementation, it is recommended
29
30
  # you do so in config/initializers/arturo_initializer.rb
30
31
  # @return [Object] the recipient of features.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'arturo/no_such_feature'
2
3
 
3
4
  module Arturo
@@ -36,32 +37,45 @@ module Arturo
36
37
  class << base
37
38
  prepend PrependMethods
38
39
  attr_accessor :cache_ttl, :feature_cache, :feature_caching_strategy
40
+ attr_writer :extend_cache_on_failure
39
41
  end
40
42
  base.send(:after_save) do |f|
41
43
  f.class.feature_caching_strategy.expire(f.class.feature_cache, f.symbol.to_sym) if f.class.caches_features?
42
44
  end
43
45
  base.cache_ttl = 0
46
+ base.extend_cache_on_failure = false
44
47
  base.feature_cache = Arturo::FeatureCaching::Cache.new
45
48
  base.feature_caching_strategy = AllStrategy
46
49
  end
47
50
 
51
+ def extend_cache_on_failure?
52
+ !!@extend_cache_on_failure
53
+ end
54
+
48
55
  def caches_features?
49
56
  self.cache_ttl.to_i > 0
50
57
  end
51
58
 
52
59
  def warm_cache!
53
- warn "Deprecated, no longer necessary!"
60
+ to_feature(:fake_feature_to_force_cache_warming)
54
61
  end
55
62
 
56
63
  class AllStrategy
57
64
  class << self
65
+ ##
66
+ # @param cache [Arturo::Cache] cache backend
67
+ # @param symbol [Symbol] arturo identifier
68
+ # @return [Arturo::Feature, Arturo::NoSuchFeature]
69
+ #
58
70
  def fetch(cache, symbol, &block)
59
- features = cache.read("arturo.all")
60
-
61
- unless cache_is_current?(cache, features)
62
- features = Hash[Arturo::Feature.all.map { |f| [f.symbol.to_sym, f] }]
63
- mark_as_current!(cache)
64
- cache.write("arturo.all", features, :expires_in => Arturo::Feature.cache_ttl * 10)
71
+ existing_features = cache.read("arturo.all")
72
+
73
+ features = if cache_is_current?(cache, existing_features)
74
+ existing_features
75
+ else
76
+ arturos_from_origin(fallback: existing_features).tap do |updated_features|
77
+ update_and_extend_cache!(cache, updated_features)
78
+ end
65
79
  end
66
80
 
67
81
  features[symbol] || Arturo::NoSuchFeature.new(symbol)
@@ -71,17 +85,96 @@ module Arturo
71
85
  cache.delete("arturo.all")
72
86
  end
73
87
 
88
+ def register_cache_update_listener(&block)
89
+ cache_update_listeners << block
90
+ end
91
+
74
92
  private
75
93
 
94
+ def cache_update_listeners
95
+ @cache_update_listeners ||= []
96
+ end
97
+
98
+ ##
99
+ # @param fallback [Hash] features to use on database failure
100
+ # @return [Hash] updated features from origin or fallback
101
+ # @raise [ActiveRecord::ActiveRecordError] on database failure
102
+ # without cache extension option
103
+ #
104
+ def arturos_from_origin(fallback:)
105
+ Arturo::Feature.all.to_h { |f| [f.symbol.to_sym, f] }
106
+ rescue ActiveRecord::ActiveRecordError
107
+ raise unless Arturo::Feature.extend_cache_on_failure?
108
+
109
+ if fallback.blank?
110
+ log_empty_cache
111
+ raise
112
+ else
113
+ log_stale_cache
114
+ fallback
115
+ end
116
+ end
117
+
118
+ ##
119
+ # @return [Boolean] whether the current cache has to be updated from origin
120
+ # @raise [ActiveRecord::ActiveRecordError] on database failure
121
+ # without cache extension option
122
+ #
76
123
  def cache_is_current?(cache, features)
77
124
  return unless features
78
125
  return true if cache.read("arturo.current")
79
- return false if features.values.map(&:updated_at).compact.max != Arturo::Feature.maximum(:updated_at)
126
+
127
+ begin
128
+ return false if origin_changed?(features)
129
+ rescue ActiveRecord::ActiveRecordError
130
+ raise unless Arturo::Feature.extend_cache_on_failure?
131
+
132
+ if features.blank?
133
+ log_empty_cache
134
+ raise
135
+ else
136
+ log_stale_cache
137
+ update_and_extend_cache!(cache, features)
138
+ end
139
+
140
+ return true
141
+ end
80
142
  mark_as_current!(cache)
81
143
  end
82
144
 
145
+ def formatted_log(namespace, msg)
146
+ "[Arturo][#{namespace}] #{msg}"
147
+ end
148
+
149
+ def log_empty_cache
150
+ Arturo.logger.error(formatted_log('extend_cache_on_failure', 'Fallback cache is empty'))
151
+ end
152
+
153
+ def log_stale_cache
154
+ Arturo.logger.warn(formatted_log('extend_cache_on_failure', 'Falling back to stale cache'))
155
+ end
156
+
157
+ ##
158
+ # @return [True]
159
+ #
83
160
  def mark_as_current!(cache)
84
- cache.write("arturo.current", true, :expires_in => Arturo::Feature.cache_ttl)
161
+ cache.write("arturo.current", true, expires_in: Arturo::Feature.cache_ttl)
162
+ end
163
+
164
+ ##
165
+ # The Arturo origin might return a big payload, so checking for the latest
166
+ # update is a cheaper operation.
167
+ #
168
+ # @return [Boolean] if origin has been updated since the last cache update.
169
+ #
170
+ def origin_changed?(features)
171
+ features.values.map(&:updated_at).compact.max != Arturo::Feature.maximum(:updated_at)
172
+ end
173
+
174
+ def update_and_extend_cache!(cache, features)
175
+ mark_as_current!(cache)
176
+ cache.write("arturo.all", features, expires_in: Arturo::Feature.cache_ttl * 10)
177
+ cache_update_listeners.each(&:call)
85
178
  end
86
179
  end
87
180
  end
@@ -91,7 +184,7 @@ module Arturo
91
184
  if feature = cache.read("arturo.#{symbol}")
92
185
  feature
93
186
  else
94
- cache.write("arturo.#{symbol}", yield || Arturo::NoSuchFeature.new(symbol), :expires_in => Arturo::Feature.cache_ttl)
187
+ cache.write("arturo.#{symbol}", yield || Arturo::NoSuchFeature.new(symbol), expires_in: Arturo::Feature.cache_ttl)
95
188
  end
96
189
  end
97
190
 
@@ -1,8 +1,9 @@
1
- FactoryGirl.define do
1
+ # frozen_string_literal: true
2
+ FactoryBot.define do
2
3
  sequence(:feature_symbol) { |n| "feature_#{n}".to_sym }
3
4
 
4
5
  factory :feature, :class => Arturo::Feature do
5
6
  symbol { generate(:feature_symbol) }
6
7
  deployment_percentage { rand(101) }
7
8
  end
8
- end
9
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
 
3
4
  # A mixin that is included by Arturo::FeaturesController and is declared
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
 
3
4
  # Mix in to FeaturesController. Provides the logic for getting parameters
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
  # A Rack middleware that requires a feature to be present. By default,
3
4
  # checks feature availability against an `arturo.recipient` object
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
 
3
4
  # A Null-Object stand-in for a Feature.
@@ -0,0 +1,8 @@
1
+ module Arturo
2
+ class NullLogger
3
+ %w[info debug error fatal warn].each do |level|
4
+ define_method level do |_message|
5
+ end
6
+ end
7
+ end
8
+ end
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
1
5
  module Arturo
2
6
 
3
7
  # Adds whitelist and blacklist support to individual features by name
@@ -24,13 +28,9 @@ module Arturo
24
28
  # This is particularly important if your application runs in several
25
29
  # different processes or on several servers.
26
30
  module SpecialHandling
31
+ extend ActiveSupport::Concern
27
32
 
28
- def self.included(base)
29
- base.extend Arturo::SpecialHandling::ClassMethods
30
- end
31
-
32
- module ClassMethods
33
-
33
+ class_methods do
34
34
  def whitelists
35
35
  @whitelists ||= []
36
36
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  Arturo.instance_eval do
2
3
 
3
4
  # Enable a feature; create it if necessary.
@@ -9,7 +10,7 @@ Arturo.instance_eval do
9
10
  def enable_feature!(name)
10
11
  if feature = Arturo::Feature.find_feature(name)
11
12
  feature = feature.class.find(feature.id) if feature.frozen?
12
- feature.update_attributes(:deployment_percentage => 100)
13
+ feature.update(:deployment_percentage => 100)
13
14
  else
14
15
  Arturo::Feature.create!(:symbol => name, :deployment_percentage => 100)
15
16
  end
@@ -24,7 +25,7 @@ Arturo.instance_eval do
24
25
  def disable_feature!(name)
25
26
  if feature = Arturo::Feature.find_feature(name)
26
27
  feature = feature.class.find(feature.id) if feature.frozen?
27
- feature.update_attributes(:deployment_percentage => 0)
28
+ feature.update(:deployment_percentage => 0)
28
29
  end
29
30
  end
30
31
 
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Arturo
3
+ VERSION = '3.0.0'
4
+ end
data/lib/arturo.rb CHANGED
@@ -1,10 +1,12 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
-
3
+ require 'arturo/null_logger'
3
4
  require 'arturo/special_handling'
5
+ require 'arturo/feature'
4
6
  require 'arturo/feature_availability'
5
7
  require 'arturo/feature_management'
6
8
  require 'arturo/feature_caching'
7
- require 'arturo/engine' if defined?(Rails::VERSION::MAJOR) && Rails::VERSION::MAJOR >= 3
9
+ require 'arturo/engine' if defined?(Rails)
8
10
 
9
11
  class << self
10
12
  # Quick check for whether a feature is enabled for a recipient.
@@ -15,5 +17,13 @@ module Arturo
15
17
  f = self::Feature.to_feature(feature_name)
16
18
  f && f.enabled_for?(recipient)
17
19
  end
20
+
21
+ def logger=(logger)
22
+ @logger = logger
23
+ end
24
+
25
+ def logger
26
+ @logger || NullLogger.new
27
+ end
18
28
  end
19
29
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators'
2
3
 
3
4
  module Arturo
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ require 'rails/generators'
3
+
4
+ module Arturo
5
+ class FeatureModelGenerator < Rails::Generators::Base
6
+ def self.source_root
7
+ File.join(File.dirname(__FILE__), 'templates')
8
+ end
9
+
10
+ def copy_feature_model_file
11
+ copy_file "feature.rb", "app/models/arturo/feature.rb"
12
+ end
13
+ end
14
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators'
2
3
 
3
4
  module Arturo
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rails/generators'
2
4
  require 'rails/generators/migration'
5
+ require 'rails/generators/active_record'
3
6
 
4
7
  module Arturo
5
8
  class MigrationGenerator < Rails::Generators::Base
@@ -9,19 +12,16 @@ module Arturo
9
12
  File.join(File.dirname(__FILE__), 'templates')
10
13
  end
11
14
 
12
- # Implement the required interface for Rails::Generators::Migration.
13
- # taken from
14
- # http://github.com/rails/rails/blob/master/activerecord/lib/generators/active_record.rb
15
- def self.next_migration_number(dirname) #:nodoc:
16
- if ActiveRecord::Base.timestamped_migrations
17
- Time.now.utc.strftime("%Y%m%d%H%M%S")
18
- else
19
- "%.3d" % (current_migration_number(dirname) + 1)
20
- end
15
+ def self.next_migration_number(dirname)
16
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
21
17
  end
22
18
 
23
19
  def create_migration_file
24
- migration_template 'migration.rb', 'db/migrate/create_features.rb'
20
+ migration_template 'migration.erb', 'db/migrate/create_features.rb', { migration_version: migration_version }
21
+ end
22
+
23
+ def migration_version
24
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
25
25
  end
26
26
  end
27
27
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators'
2
3
 
3
4
  module Arturo
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module Arturo
6
+ class Feature < ::ActiveRecord::Base
7
+ include Arturo::FeatureMethods
8
+ end
9
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'arturo'
2
3
  require 'arturo/feature'
3
4
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateFeatures < ActiveRecord::Migration<%= migration_version %>
4
+ def self.up
5
+ create_table :features do |t|
6
+ t.string :symbol, null: false
7
+ t.integer :deployment_percentage, null: false
8
+ # Any additional fields here
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+
14
+ def self.down
15
+ drop_table :features
16
+ end
17
+ end