From 859a6fb938bb9ee2a317c46dfa4fcc1af49608f0 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 18 Feb 2021 10:34:06 +0000 Subject: Add latest changes from gitlab-org/gitlab@13-9-stable-ee --- app/experiments/application_experiment.rb | 45 +++++++++++-- app/experiments/members/invite_email_experiment.rb | 18 +++++ app/experiments/new_project_readme_experiment.rb | 45 +++++++++++++ app/experiments/strategy/round_robin.rb | 78 ++++++++++++++++++++++ 4 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 app/experiments/members/invite_email_experiment.rb create mode 100644 app/experiments/new_project_readme_experiment.rb create mode 100644 app/experiments/strategy/round_robin.rb (limited to 'app/experiments') diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index 7a8851d11ce..317514d088b 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -1,13 +1,20 @@ # frozen_string_literal: true -class ApplicationExperiment < Gitlab::Experiment +class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/NamespacedClass + def enabled? + return false if Feature::Definition.get(feature_flag_name).nil? # there has to be a feature flag yaml file + return false unless Gitlab.dev_env_or_com? # we're in an environment that allows experiments + + Feature.get(feature_flag_name).state != :off # rubocop:disable Gitlab/AvoidFeatureGet + end + def publish(_result) track(:assignment) # track that we've assigned a variant for this context Gon.global.push({ experiment: { name => signature } }, true) # push to client end def track(action, **event_args) - return if excluded? # no events for opted out actors or excluded subjects + return unless should_track? # no events for opted out actors or excluded subjects Gitlab::Tracking.event(name, action.to_s, **event_args.merge( context: (event_args[:context] || []) << SnowplowTracker::SelfDescribingJson.new( @@ -16,10 +23,39 @@ class ApplicationExperiment < Gitlab::Experiment )) end + def rollout_strategy + # no-op override in inherited class as desired + end + + def variants + # override as desired in inherited class with all variants + control + # %i[variant1 variant2 control] + # + # this will make sure we supply variants as these go together - rollout_strategy of :round_robin must have variants + raise NotImplementedError, "Inheriting class must supply variants as an array if :round_robin strategy is used" if rollout_strategy == :round_robin + end + private + def feature_flag_name + name.tr('/', '_') + end + def resolve_variant_name - return variant_names.first if Feature.enabled?(name, self, type: :experiment) + case rollout_strategy + when :round_robin + round_robin_rollout + else + percentage_rollout + end + end + + def round_robin_rollout + Strategy::RoundRobin.new(feature_flag_name, variants).execute + end + + def percentage_rollout + return variant_names.first if Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml) nil # Returning nil vs. :control is important for not caching and rollouts. end @@ -41,7 +77,7 @@ class ApplicationExperiment < Gitlab::Experiment # default cache key strategy. So running `cache.fetch("foo:bar", "value")` # would create/update a hash with the key of "foo", with a field named # "bar" that has "value" assigned to it. - class Cache < ActiveSupport::Cache::Store + class Cache < ActiveSupport::Cache::Store # rubocop:disable Gitlab/NamespacedClass # Clears the entire cache for a given experiment. Be careful with this # since it would reset all resolved variants for the entire experiment. def clear(key:) @@ -72,7 +108,6 @@ class ApplicationExperiment < Gitlab::Experiment end def write_entry(key, entry, **options) - return false unless Feature.enabled?(:caching_experiments) return false if entry.value.blank? # don't cache any empty values pool { |redis| redis.hset(*hkey(key), entry.value) } diff --git a/app/experiments/members/invite_email_experiment.rb b/app/experiments/members/invite_email_experiment.rb new file mode 100644 index 00000000000..4a03ebb7726 --- /dev/null +++ b/app/experiments/members/invite_email_experiment.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Members + class InviteEmailExperiment < ApplicationExperiment + exclude { context.actor.created_by.blank? } + exclude { context.actor.created_by.avatar_url.nil? } + + INVITE_TYPE = 'initial_email' + + def rollout_strategy + :round_robin + end + + def variants + %i[avatar permission_info control] + end + end +end diff --git a/app/experiments/new_project_readme_experiment.rb b/app/experiments/new_project_readme_experiment.rb new file mode 100644 index 00000000000..8f88ad2adc1 --- /dev/null +++ b/app/experiments/new_project_readme_experiment.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class NewProjectReadmeExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass + include Gitlab::Git::WrapsGitalyErrors + + INITIAL_WRITE_LIMIT = 3 + EXPERIMENT_START_DATE = DateTime.parse('2021/1/20') + MAX_ACCOUNT_AGE = 7.days + + exclude { context.value[:actor].nil? } + exclude { context.actor.created_at < MAX_ACCOUNT_AGE.ago } + + def control_behavior + false # we don't want the checkbox to be checked + end + + def candidate_behavior + true # check the checkbox by default + end + + def track_initial_writes(project) + return unless should_track? # early return if we don't need to ask for commit counts + return unless project.created_at > EXPERIMENT_START_DATE # early return for older projects + return unless (commit_count = commit_count_for(project)) < INITIAL_WRITE_LIMIT + + track(:write, property: project.created_at.to_s, value: commit_count) + end + + private + + def commit_count_for(project) + raw_repo = project.repository&.raw_repository + return INITIAL_WRITE_LIMIT unless raw_repo&.root_ref + + begin + Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(raw_repo.root_ref, { + all: true, # include all branches + max_count: INITIAL_WRITE_LIMIT # limit as an optimization + }) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, experiment: name) + INITIAL_WRITE_LIMIT + end + end +end diff --git a/app/experiments/strategy/round_robin.rb b/app/experiments/strategy/round_robin.rb new file mode 100644 index 00000000000..7b80c0e984d --- /dev/null +++ b/app/experiments/strategy/round_robin.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Strategy + class RoundRobin + CacheError = Class.new(StandardError) + + COUNTER_EXPIRE_TIME = 86400 # one day + + def initialize(key, variants) + @key = key + @variants = variants + end + + def execute + increment_counter + resolve_variant_name + end + + # When the counter would expire + # + # @api private Used internally by SRE and debugging purpose + # @return [Integer] Number in seconds until expiration or false if never + def counter_expires_in + Gitlab::Redis::SharedState.with do |redis| + redis.ttl(key) + end + end + + # Return the actual counter value + # + # @return [Integer] value + def counter_value + Gitlab::Redis::SharedState.with do |redis| + (redis.get(key) || 0).to_i + end + end + + # Reset the counter + # + # @private Used internally by SRE and debugging purpose + # @return [Boolean] whether reset was a success + def reset! + redis_cmd do |redis| + redis.del(key) + end + end + + private + + attr_reader :key, :variants + + # Increase the counter + # + # @return [Boolean] whether operation was a success + def increment_counter + redis_cmd do |redis| + redis.incr(key) + redis.expire(key, COUNTER_EXPIRE_TIME) + end + end + + def resolve_variant_name + remainder = counter_value % variants.size + + variants[remainder] + end + + def redis_cmd + Gitlab::Redis::SharedState.with { |redis| yield(redis) } + + true + rescue CacheError => e + Gitlab::AppLogger.warn("GitLab: An unexpected error occurred in writing to Redis: #{e}") + + false + end + end +end -- cgit v1.2.3