2018-12-13 13:39:08 +05:30
# frozen_string_literal: true
2018-11-08 19:23:39 +05:30
require 'flipper/adapters/active_record'
require 'flipper/adapters/active_support_cache_store'
2022-08-13 15:12:31 +05:30
module Feature
2017-09-10 17:25:29 +05:30
# Classes to override flipper table names
class FlipperFeature < Flipper :: Adapters :: ActiveRecord :: Feature
2021-12-11 22:18:48 +05:30
include DatabaseReflection
2017-09-10 17:25:29 +05:30
# Using `self.table_name` won't work. ActiveRecord bug?
superclass . table_name = 'features'
2018-03-17 18:26:18 +05:30
def self . feature_names
pluck ( :key )
end
2017-09-10 17:25:29 +05:30
end
2023-03-04 22:38:38 +05:30
class OptOut
def initialize ( inner )
@inner = inner
end
def flipper_id
" #{ @inner . flipper_id } :opt_out "
end
end
2017-09-10 17:25:29 +05:30
class FlipperGate < Flipper :: Adapters :: ActiveRecord :: Gate
superclass . table_name = 'feature_gates'
end
2021-09-04 01:27:46 +05:30
# To enable EE overrides
class ActiveSupportCacheStoreAdapter < Flipper :: Adapters :: ActiveSupportCacheStore
end
2020-06-23 00:09:42 +05:30
InvalidFeatureFlagError = Class . new ( Exception ) # rubocop:disable Lint/InheritException
2023-03-04 22:38:38 +05:30
InvalidOperation = Class . new ( ArgumentError ) # rubocop:disable Lint/InheritException
2020-06-23 00:09:42 +05:30
2017-09-10 17:25:29 +05:30
class << self
delegate :group , to : :flipper
2022-03-02 08:16:31 +05:30
def feature_flags_available?
# When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised
2022-08-27 11:52:29 +05:30
active_db_connection = begin
ActiveRecord :: Base . connection . active? # rubocop:disable Database/MultipleDatabases
rescue StandardError
false
end
2022-03-02 08:16:31 +05:30
active_db_connection && Feature :: FlipperFeature . table_exists?
rescue ActiveRecord :: NoDatabaseError
false
end
2017-09-10 17:25:29 +05:30
def all
flipper . features . to_a
end
2022-07-16 23:28:13 +05:30
RecursionError = Class . new ( RuntimeError )
2017-09-10 17:25:29 +05:30
def get ( key )
2022-07-16 23:28:13 +05:30
with_feature ( key , & :itself )
2017-09-10 17:25:29 +05:30
end
2018-03-17 18:26:18 +05:30
def persisted_names
2021-12-11 22:18:48 +05:30
return [ ] unless ApplicationRecord . database . exists?
2020-03-13 15:44:24 +05:30
2020-07-28 23:09:34 +05:30
# This loads names of all stored feature flags
# and returns a stable Set in the following order:
# - Memoized: using Gitlab::SafeRequestStore or @flipper
# - L1: using Process cache
# - L2: using Redis cache
# - DB: using a single SQL query
flipper . adapter . features
2018-03-17 18:26:18 +05:30
end
2020-06-23 00:09:42 +05:30
def persisted_name? ( feature_name )
2017-09-10 17:25:29 +05:30
# Flipper creates on-memory features when asked for a not-yet-created one.
# If we want to check if a feature has been actually set, we look for it
# on the persisted features list.
2020-06-23 00:09:42 +05:30
persisted_names . include? ( feature_name . to_s )
2017-09-10 17:25:29 +05:30
end
2022-07-16 23:28:13 +05:30
# The default state of feature flag is read from `YAML`:
# 1. If feature flag does not have YAML it will fallback to `default_enabled: false`
# in production environment, but raise exception in development or tests.
# 2. The `default_enabled_if_undefined:` is tech debt related to Gitaly flags
# and should not be used outside of Gitaly's `lib/feature/gitaly.rb`
def enabled? ( key , thing = nil , type : :development , default_enabled_if_undefined : nil )
2020-06-23 00:09:42 +05:30
if check_feature_flags_definition?
2023-03-04 22:38:38 +05:30
if thing && ! thing . respond_to? ( :flipper_id ) && ! thing . is_a? ( Flipper :: Types :: Group )
2020-06-23 00:09:42 +05:30
raise InvalidFeatureFlagError ,
" The thing ' #{ thing . class . name } ' for feature flag ' #{ key } ' needs to include `FeatureGate` or implement `flipper_id` "
end
2020-07-28 23:09:34 +05:30
2022-07-16 23:28:13 +05:30
Feature :: Definition . valid_usage! ( key , type : type )
2020-06-23 00:09:42 +05:30
end
2022-07-16 23:28:13 +05:30
default_enabled = Feature :: Definition . default_enabled? ( key , default_enabled_if_undefined : default_enabled_if_undefined )
2023-03-04 22:38:38 +05:30
feature_value = current_feature_value ( key , thing , default_enabled : default_enabled )
2018-11-20 20:47:30 +05:30
2022-07-16 23:28:13 +05:30
# If not yielded, then either recursion is happening, or the database does not exist yet, so use default_enabled.
feature_value = default_enabled if feature_value . nil?
2022-01-26 12:08:38 +05:30
# If we don't filter out this flag here we will enter an infinite loop
log_feature_flag_state ( key , feature_value ) if log_feature_flag_states? ( key )
feature_value
2017-09-10 17:25:29 +05:30
end
2022-07-16 23:28:13 +05:30
def disabled? ( key , thing = nil , type : :development , default_enabled_if_undefined : nil )
2018-11-20 20:47:30 +05:30
# we need to make different method calls to make it easy to mock / define expectations in test mode
2022-07-16 23:28:13 +05:30
thing . nil? ? ! enabled? ( key , type : type , default_enabled_if_undefined : default_enabled_if_undefined ) : ! enabled? ( key , thing , type : type , default_enabled_if_undefined : default_enabled_if_undefined )
2018-11-18 11:00:15 +05:30
end
2017-09-10 17:25:29 +05:30
def enable ( key , thing = true )
2021-02-22 17:27:13 +05:30
log ( key : key , action : __method__ , thing : thing )
2023-03-04 22:38:38 +05:30
2022-08-13 15:12:31 +05:30
return_value = with_feature ( key ) { _1 . enable ( thing ) }
# rubocop:disable Gitlab/RailsLogger
Rails . logger . warn ( 'WARNING: Understand the stability and security risks of enabling in-development features with feature flags.' )
Rails . logger . warn ( 'See https://docs.gitlab.com/ee/administration/feature_flags.html#risks-when-enabling-features-still-in-development for more information.' )
# rubocop:enable Gitlab/RailsLogger
return_value
2017-09-10 17:25:29 +05:30
end
def disable ( key , thing = false )
2021-02-22 17:27:13 +05:30
log ( key : key , action : __method__ , thing : thing )
2023-03-04 22:38:38 +05:30
2022-07-16 23:28:13 +05:30
with_feature ( key ) { _1 . disable ( thing ) }
2017-09-10 17:25:29 +05:30
end
2023-03-04 22:38:38 +05:30
def opted_out? ( key , thing )
return false unless thing . respond_to? ( :flipper_id ) # Ignore Feature::Types::Group
return false unless persisted_name? ( key )
opt_out = OptOut . new ( thing )
with_feature ( key ) { _1 . actors_value . include? ( opt_out . flipper_id ) }
end
def opt_out ( key , thing )
return unless thing . respond_to? ( :flipper_id ) # Ignore Feature::Types::Group
log ( key : key , action : __method__ , thing : thing )
opt_out = OptOut . new ( thing )
with_feature ( key ) { _1 . enable ( opt_out ) }
end
def remove_opt_out ( key , thing )
return unless thing . respond_to? ( :flipper_id ) # Ignore Feature::Types::Group
return unless persisted_name? ( key )
log ( key : key , action : __method__ , thing : thing )
opt_out = OptOut . new ( thing )
with_feature ( key ) { _1 . disable ( opt_out ) }
end
2020-06-23 00:09:42 +05:30
def enable_percentage_of_time ( key , percentage )
2021-02-22 17:27:13 +05:30
log ( key : key , action : __method__ , percentage : percentage )
2023-03-04 22:38:38 +05:30
with_feature ( key ) do | flag |
raise InvalidOperation , 'Cannot enable percentage of time for a fully-enabled flag' if flag . state == :on
flag . enable_percentage_of_time ( percentage )
end
2020-06-23 00:09:42 +05:30
end
2019-10-12 21:52:04 +05:30
2020-06-23 00:09:42 +05:30
def disable_percentage_of_time ( key )
2021-02-22 17:27:13 +05:30
log ( key : key , action : __method__ )
2022-07-16 23:28:13 +05:30
with_feature ( key , & :disable_percentage_of_time )
2019-10-12 21:52:04 +05:30
end
2020-06-23 00:09:42 +05:30
def enable_percentage_of_actors ( key , percentage )
2021-02-22 17:27:13 +05:30
log ( key : key , action : __method__ , percentage : percentage )
2023-03-04 22:38:38 +05:30
with_feature ( key ) do | flag |
raise InvalidOperation , 'Cannot enable percentage of actors for a fully-enabled flag' if flag . state == :on
flag . enable_percentage_of_actors ( percentage )
end
2018-11-08 19:23:39 +05:30
end
2020-06-23 00:09:42 +05:30
def disable_percentage_of_actors ( key )
2021-02-22 17:27:13 +05:30
log ( key : key , action : __method__ )
2022-07-16 23:28:13 +05:30
with_feature ( key , & :disable_percentage_of_actors )
2020-06-23 00:09:42 +05:30
end
def remove ( key )
return unless persisted_name? ( key )
2021-02-22 17:27:13 +05:30
log ( key : key , action : __method__ )
2023-03-04 22:38:38 +05:30
2022-07-16 23:28:13 +05:30
with_feature ( key , & :remove )
2020-06-23 00:09:42 +05:30
end
def reset
Gitlab :: SafeRequestStore . delete ( :flipper ) if Gitlab :: SafeRequestStore . active?
@flipper = nil
2017-09-10 17:25:29 +05:30
end
# This method is called from config/initializers/flipper.rb and can be used
# to register Flipper groups.
2021-03-11 19:13:27 +05:30
# See https://docs.gitlab.com/ee/development/feature_flags/index.html
2017-09-10 17:25:29 +05:30
def register_feature_groups
2023-04-23 21:23:45 +05:30
Flipper . register ( :gitlab_team_members ) { | actor | FeatureGroups :: GitlabTeamMembers . enabled? ( actor . thing ) }
2017-09-10 17:25:29 +05:30
end
2018-11-08 19:23:39 +05:30
2020-07-28 23:09:34 +05:30
def register_definitions
2021-01-03 14:25:43 +05:30
Feature :: Definition . reload!
2020-07-28 23:09:34 +05:30
end
2020-11-24 15:15:51 +05:30
def register_hot_reloader
return unless check_feature_flags_definition?
Feature :: Definition . register_hot_reloader!
end
2021-02-22 17:27:13 +05:30
def logger
@logger || = Feature :: Logger . build
end
2022-01-26 12:08:38 +05:30
def log_feature_flag_states? ( key )
Feature :: Definition . log_states? ( key )
end
def log_feature_flag_state ( key , feature_value )
logged_states [ key ] || = feature_value
end
def logged_states
RequestStore . fetch ( :feature_flag_events ) { { } }
end
2020-06-23 00:09:42 +05:30
private
2023-03-04 22:38:38 +05:30
# Compute if thing is enabled, taking opt-out overrides into account
2022-07-16 23:28:13 +05:30
# Evaluate if `default enabled: false` or the feature has been persisted.
# `persisted_name?` can potentially generate DB queries and also checks for inclusion
# in an array of feature names (177 at last count), possibly reducing performance by half.
# So we only perform the `persisted` check if `default_enabled: true`
2023-03-04 22:38:38 +05:30
def current_feature_value ( key , thing , default_enabled : )
with_feature ( key ) do | feature |
if default_enabled && ! Feature . persisted_name? ( feature . name )
true
else
enabled = feature . enabled? ( thing )
if enabled && ! thing . nil?
opt_out = OptOut . new ( thing )
feature . actors_value . exclude? ( opt_out . flipper_id )
else
enabled
end
end
end
2022-07-16 23:28:13 +05:30
end
# NOTE: it is not safe to call `Flipper::Feature#enabled?` outside the block
def with_feature ( key )
feature = unsafe_get ( key )
yield feature if feature . present?
ensure
pop_recursion_stack
end
def unsafe_get ( key )
# During setup the database does not exist yet. So we haven't stored a value
# for the feature yet and return the default.
return unless ApplicationRecord . database . exists?
flag_stack = :: Thread . current [ :feature_flag_recursion_check ] || [ ]
Thread . current [ :feature_flag_recursion_check ] = flag_stack
# Prevent more than 10 levels of recursion. This limit was chosen as a fairly
# low limit while allowing some nesting of flag evaluation. We have not seen
# this limit hit in production.
if flag_stack . size > 10
Gitlab :: ErrorTracking . track_exception ( RecursionError . new ( 'deep recursion' ) , stack : flag_stack )
return
elsif flag_stack . include? ( key )
Gitlab :: ErrorTracking . track_exception ( RecursionError . new ( 'self recursion' ) , stack : flag_stack )
return
end
flag_stack . push ( key )
flipper . feature ( key )
end
def pop_recursion_stack
flag_stack = Thread . current [ :feature_flag_recursion_check ]
flag_stack . pop if flag_stack
end
2020-06-23 00:09:42 +05:30
def flipper
if Gitlab :: SafeRequestStore . active?
2021-12-11 22:18:48 +05:30
Gitlab :: SafeRequestStore [ :flipper ] || = build_flipper_instance ( memoize : true )
2020-06-23 00:09:42 +05:30
else
@flipper || = build_flipper_instance
end
end
2021-12-11 22:18:48 +05:30
def build_flipper_instance ( memoize : false )
2018-11-08 19:23:39 +05:30
active_record_adapter = Flipper :: Adapters :: ActiveRecord . new (
feature_class : FlipperFeature ,
gate_class : FlipperGate )
2019-09-30 21:07:59 +05:30
# Redis L2 cache
redis_cache_adapter =
2020-11-24 15:15:51 +05:30
ActiveSupportCacheStoreAdapter . new (
2019-09-30 21:07:59 +05:30
active_record_adapter ,
l2_cache_backend ,
2021-09-04 01:27:46 +05:30
expires_in : 1 . hour ,
write_through : true )
2019-09-30 21:07:59 +05:30
# Thread-local L1 cache: use a short timeout since we don't have a
# way to expire this cache all at once
2020-06-23 00:09:42 +05:30
flipper_adapter = Flipper :: Adapters :: ActiveSupportCacheStore . new (
2019-09-30 21:07:59 +05:30
redis_cache_adapter ,
l1_cache_backend ,
expires_in : 1 . minute )
2020-06-23 00:09:42 +05:30
Flipper . new ( flipper_adapter ) . tap do | flip |
2021-12-11 22:18:48 +05:30
flip . memoize = memoize
2020-06-23 00:09:42 +05:30
end
end
def check_feature_flags_definition?
# We want to check feature flags usage only when
# running in development or test environment
Gitlab . dev_or_test_env?
2019-09-30 21:07:59 +05:30
end
def l1_cache_backend
2020-05-24 23:13:21 +05:30
Gitlab :: ProcessMemoryCache . cache_backend
2019-09-30 21:07:59 +05:30
end
def l2_cache_backend
Rails . cache
2018-11-08 19:23:39 +05:30
end
2021-02-22 17:27:13 +05:30
def log ( key : , action : , ** extra )
extra || = { }
extra = extra . transform_keys { | k | " extra. #{ k } " }
extra = extra . transform_values { | v | v . respond_to? ( :flipper_id ) ? v . flipper_id : v }
extra = extra . transform_values ( & :to_s )
logger . info ( key : key , action : action , ** extra )
end
2017-09-10 17:25:29 +05:30
end
2019-03-02 22:35:43 +05:30
class Target
2023-03-04 22:38:38 +05:30
UnknownTargetError = Class . new ( StandardError )
2022-07-23 23:45:48 +05:30
2019-03-02 22:35:43 +05:30
attr_reader :params
def initialize ( params )
@params = params
end
def gate_specified?
2023-01-13 00:05:48 +05:30
% i ( user project group feature_group namespace repository ) . any? { | key | params . key? ( key ) }
2019-03-02 22:35:43 +05:30
end
def targets
2023-01-13 00:05:48 +05:30
[ feature_group , users , projects , groups , namespaces , repositories ] . flatten . compact
2019-03-02 22:35:43 +05:30
end
private
# rubocop: disable CodeReuse/ActiveRecord
def feature_group
return unless params . key? ( :feature_group )
Feature . group ( params [ :feature_group ] )
end
# rubocop: enable CodeReuse/ActiveRecord
2022-07-23 23:45:48 +05:30
def users
2019-03-02 22:35:43 +05:30
return unless params . key? ( :user )
2022-07-23 23:45:48 +05:30
params [ :user ] . split ( ',' ) . map do | arg |
2023-03-04 22:38:38 +05:30
UserFinder . new ( arg ) . find_by_username || ( raise UnknownTargetError , " #{ arg } is not found! " )
2022-07-23 23:45:48 +05:30
end
2019-03-02 22:35:43 +05:30
end
2022-07-23 23:45:48 +05:30
def projects
2019-03-02 22:35:43 +05:30
return unless params . key? ( :project )
2022-07-23 23:45:48 +05:30
params [ :project ] . split ( ',' ) . map do | arg |
2023-03-04 22:38:38 +05:30
Project . find_by_full_path ( arg ) || ( raise UnknownTargetError , " #{ arg } is not found! " )
2022-07-23 23:45:48 +05:30
end
2019-03-02 22:35:43 +05:30
end
2019-07-07 11:18:12 +05:30
2022-07-23 23:45:48 +05:30
def groups
2019-07-07 11:18:12 +05:30
return unless params . key? ( :group )
2022-07-23 23:45:48 +05:30
params [ :group ] . split ( ',' ) . map do | arg |
2023-03-04 22:38:38 +05:30
Group . find_by_full_path ( arg ) || ( raise UnknownTargetError , " #{ arg } is not found! " )
2022-07-23 23:45:48 +05:30
end
2019-07-07 11:18:12 +05:30
end
2022-04-04 11:22:00 +05:30
2022-07-23 23:45:48 +05:30
def namespaces
2022-04-04 11:22:00 +05:30
return unless params . key? ( :namespace )
2022-07-23 23:45:48 +05:30
params [ :namespace ] . split ( ',' ) . map do | arg |
# We are interested in Group or UserNamespace
2023-03-04 22:38:38 +05:30
Namespace . without_project_namespaces . find_by_full_path ( arg ) || ( raise UnknownTargetError , " #{ arg } is not found! " )
2022-07-23 23:45:48 +05:30
end
2022-04-04 11:22:00 +05:30
end
2023-01-13 00:05:48 +05:30
def repositories
return unless params . key? ( :repository )
params [ :repository ] . split ( ',' ) . map do | arg |
container , _project , _type , _path = Gitlab :: RepoPath . parse ( arg )
2023-03-04 22:38:38 +05:30
raise UnknownTargetError , " #{ arg } is not found! " if container . nil?
2023-01-13 00:05:48 +05:30
container . repository
end
end
2019-03-02 22:35:43 +05:30
end
2017-09-10 17:25:29 +05:30
end
2020-06-23 00:09:42 +05:30
2023-04-23 21:23:45 +05:30
Feature . prepend_mod
2021-06-08 01:23:25 +05:30
Feature :: ActiveSupportCacheStoreAdapter . prepend_mod_with ( 'Feature::ActiveSupportCacheStoreAdapter' )