340 lines
12 KiB
Ruby
340 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2011 - present Instructure, Inc.
|
|
#
|
|
# This file is part of Canvas.
|
|
#
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, version 3 of the License.
|
|
#
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
module Canvas
|
|
# defines the behavior when a protected attribute is assigned to in mass
|
|
# assignment. The default, and Rails' normal behavior, is to just :log. Set
|
|
# this to :raise to raise an exception.
|
|
mattr_accessor :protected_attribute_error
|
|
|
|
def self.active_record_foreign_key_check(name, type, options)
|
|
if name.to_s =~ /_id\z/ && type.to_s == 'integer' && options[:limit].to_i < 8
|
|
raise ArgumentError, <<-EOS
|
|
All foreign keys need to be at least 8-byte integers. #{name}
|
|
looks like a foreign key, please add this option: `:limit => 8`
|
|
EOS
|
|
end
|
|
end
|
|
|
|
def self.redis
|
|
raise "Redis is not enabled for this install" unless Canvas.redis_enabled?
|
|
if redis_config == 'cache_store' || redis_config.is_a?(Hash) && redis_config['servers'] == 'cache_store'
|
|
return Rails.cache.redis
|
|
end
|
|
@redis ||= begin
|
|
Canvas::Redis.patch
|
|
settings = ConfigFile.load('redis').dup
|
|
settings['url'] = settings['servers'] if settings['servers']
|
|
ActiveSupport::Cache::RedisCacheStore.build_redis(**settings.to_h.symbolize_keys)
|
|
end
|
|
end
|
|
|
|
def self.redis_config
|
|
@redis_config ||= ConfigFile.load('redis')
|
|
end
|
|
|
|
def self.redis_enabled?
|
|
@redis_enabled ||= redis_config.present?
|
|
end
|
|
|
|
# technically this is just disconnect_redis, because new connections are created lazily,
|
|
# but I didn't want to rename it when there are several uses of it
|
|
def self.reconnect_redis
|
|
if Rails.cache &&
|
|
defined?(ActiveSupport::Cache::RedisCacheStore) &&
|
|
Rails.cache.is_a?(ActiveSupport::Cache::RedisCacheStore)
|
|
Canvas::Redis.handle_redis_failure(nil, "none") do
|
|
redis = Rails.cache.redis
|
|
if redis.respond_to?(:nodes)
|
|
redis.nodes.each(&:disconnect!)
|
|
else
|
|
redis.disconnect!
|
|
end
|
|
end
|
|
end
|
|
|
|
if MultiCache.cache.is_a?(ActiveSupport::Cache::HaStore)
|
|
Canvas::Redis.handle_redis_failure(nil, "none") do
|
|
redis = MultiCache.cache.redis
|
|
if redis.respond_to?(:nodes)
|
|
redis.nodes.each(&:disconnect!)
|
|
else
|
|
redis.disconnect!
|
|
end
|
|
end
|
|
end
|
|
|
|
return unless @redis
|
|
# We're sharing redis connections between Canvas.redis and Rails.cache,
|
|
# so don't call reconnect on the cache too.
|
|
return if Rails.cache.respond_to?(:redis) && @redis == Rails.cache.redis
|
|
@redis = nil
|
|
end
|
|
|
|
def self.cache_store_config_for(cluster)
|
|
yaml_config = ConfigFile.load("cache_store", cluster)
|
|
consul_config = YAML.load(Canvas::DynamicSettings.find(tree: :private, cluster: cluster)["cache_store.yml"] || "{}") || {}
|
|
consul_config = consul_config.with_indifferent_access if consul_config.is_a?(Hash)
|
|
|
|
consul_config.presence || yaml_config
|
|
end
|
|
|
|
def self.lookup_cache_store(config, cluster)
|
|
config = {'cache_store' => 'nil_store'}.merge(config)
|
|
if config['cache_store'] == 'redis_store'
|
|
ActiveSupport::Deprecation.warn("`redis_store` is no longer supported. Please change to `redis_cache_store`, and change `servers` to `url`.")
|
|
config['cache_store'] = 'redis_cache_store'
|
|
config['url'] = config['servers'] if config['servers']
|
|
end
|
|
|
|
case config.delete('cache_store')
|
|
when 'redis_cache_store'
|
|
Canvas::Redis.patch
|
|
# if cache and redis data are configured identically, we want to share connections
|
|
if config == {} && cluster == Rails.env && Canvas.redis_enabled?
|
|
ActiveSupport::Cache.lookup_store(:redis_cache_store, redis: Canvas.redis)
|
|
else
|
|
# merge in redis.yml, but give precedence to cache_store.yml
|
|
redis_config = (ConfigFile.load('redis', cluster) || {})
|
|
config = redis_config.merge(config) if redis_config.is_a?(Hash)
|
|
# config has to be a vanilla hash, with symbol keys, to auto-convert to kwargs
|
|
ActiveSupport::Cache.lookup_store(:redis_cache_store, config.to_h.symbolize_keys)
|
|
end
|
|
when 'memory_store'
|
|
ActiveSupport::Cache.lookup_store(:memory_store)
|
|
when 'nil_store', 'null_store'
|
|
ActiveSupport::Cache.lookup_store(:null_store)
|
|
end
|
|
end
|
|
|
|
# `sample` reports KB, not B
|
|
if File.directory?("/proc")
|
|
# linux w/ proc fs
|
|
LINUX_PAGE_SIZE = (size = `getconf PAGESIZE`.to_i; size > 0 ? size : 4096)
|
|
def self.sample_memory
|
|
s = File.read("/proc/#{Process.pid}/statm").to_i rescue 0
|
|
s * LINUX_PAGE_SIZE / 1024
|
|
end
|
|
else
|
|
# generic unix solution
|
|
def self.sample_memory
|
|
if Rails.env.test?
|
|
0
|
|
else
|
|
# hmm this is actually resident set size, doesn't include swapped-to-disk
|
|
# memory.
|
|
`ps -o rss= -p #{Process.pid}`.to_i
|
|
end
|
|
end
|
|
end
|
|
|
|
# can be called by plugins to allow reloading of that plugin in dev mode
|
|
# pass in the path to the plugin directory
|
|
# e.g., in the vendor/plugins/<plugin_name>/init.rb or
|
|
# gems/plugins/<plugin_name>/lib/<plugin_name>/engine.rb:
|
|
# Canvas.reloadable_plugin(File.dirname(__FILE__))
|
|
def self.reloadable_plugin(dirname)
|
|
return unless Rails.env.development?
|
|
base_path = File.expand_path(dirname)
|
|
base_path.gsub(%r{/lib/[^/]*$}, '')
|
|
ActiveSupport::Dependencies.autoload_once_paths.reject! { |p|
|
|
p[0, base_path.length] == base_path
|
|
}
|
|
end
|
|
|
|
def self.revision
|
|
return @revision if defined?(@revision)
|
|
@revision = if File.file?(Rails.root+"VERSION")
|
|
File.readlines(Rails.root+"VERSION").first.try(:strip)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
DEFAULT_RETRY_CALLBACK = -> (ex, tries) {
|
|
Rails.logger.debug do
|
|
{
|
|
error_class: ex.class,
|
|
error_message: ex.message,
|
|
error_backtrace: ex.backtrace,
|
|
tries: tries,
|
|
message: "Retrying service call!"
|
|
}.to_json
|
|
end
|
|
}
|
|
|
|
DEFAULT_RETRIABLE_OPTIONS = {
|
|
interval: -> (attempts) { 0.5 + 4 ** (attempts - 1) }, # Sleeps: 0.5, 4.5, 16.5
|
|
on_retry: DEFAULT_RETRY_CALLBACK,
|
|
tries: 3,
|
|
}.freeze
|
|
def self.retriable(opts = {}, &block)
|
|
if opts[:on_retry]
|
|
original_callback = opts[:on_retry]
|
|
opts[:on_retry] = -> (ex, tries) {
|
|
original_callback.call(ex, tries)
|
|
DEFAULT_RETRY_CALLBACK.call(ex, tries)
|
|
}
|
|
end
|
|
options = DEFAULT_RETRIABLE_OPTIONS.merge(opts)
|
|
Retriable.retriable(options, &block)
|
|
end
|
|
|
|
def self.installation_uuid
|
|
installation_uuid = Setting.get("installation_uuid", "")
|
|
if installation_uuid == ""
|
|
installation_uuid = SecureRandom.uuid
|
|
Setting.set("installation_uuid", installation_uuid)
|
|
end
|
|
installation_uuid
|
|
end
|
|
|
|
def self.timeout_protection_error_ttl(service_name)
|
|
(Setting.get("service_#{service_name}_error_ttl", nil) ||
|
|
Setting.get("service_generic_error_ttl", 1.minute.to_s)).to_i
|
|
end
|
|
|
|
def self.timeout_protection_method(service_name)
|
|
Setting.get("service_#{service_name}_timeout_protection_method", nil)
|
|
end
|
|
|
|
# protection against calling external services that could timeout or misbehave.
|
|
# we keep track of timeouts in redis, and if a given service times out more
|
|
# than X times before the redis key expires in Y seconds (reset on each
|
|
# failure), we stop even trying to contact the service until the Y seconds
|
|
# passes.
|
|
#
|
|
# if redis isn't enabled, we'll still apply the timeout, but we won't track failures.
|
|
#
|
|
# all the configurable params have service-specific Settings with fallback to
|
|
# generic Settings.
|
|
def self.timeout_protection(service_name, options={}, &block)
|
|
timeout = (Setting.get("service_#{service_name}_timeout", nil) || options[:fallback_timeout_length] || Setting.get("service_generic_timeout", 15.seconds.to_s)).to_f
|
|
|
|
if Canvas.redis_enabled?
|
|
if timeout_protection_method(service_name) == "percentage"
|
|
percent_short_circuit_timeout(Canvas.redis, service_name, timeout, &block)
|
|
else
|
|
short_circuit_timeout(Canvas.redis, service_name, timeout, &block)
|
|
end
|
|
else
|
|
Timeout.timeout(timeout, &block)
|
|
end
|
|
rescue TimeoutCutoff, Timeout::Error => e
|
|
log_message = if e.is_a?(TimeoutCutoff)
|
|
"Skipping service call due to error count: #{service_name} #{e.error_count}"
|
|
else
|
|
"Timeout during service call: #{service_name}"
|
|
end
|
|
Rails.logger.error(log_message)
|
|
Canvas::Errors.capture_exception(:service_timeout, e, :warn)
|
|
raise if options[:raise_on_timeout]
|
|
return nil
|
|
end
|
|
|
|
def self.timeout_protection_cutoff(service_name)
|
|
(Setting.get("service_#{service_name}_cutoff", nil) ||
|
|
Setting.get("service_generic_cutoff", 3.to_s)).to_i
|
|
end
|
|
|
|
def self.short_circuit_timeout(redis, service_name, timeout, &block)
|
|
redis_key = "service:timeouts:#{service_name}:error_count"
|
|
cutoff = timeout_protection_cutoff(service_name)
|
|
|
|
error_count = redis.get(redis_key)
|
|
if error_count.to_i >= cutoff
|
|
raise TimeoutCutoff.new(error_count)
|
|
end
|
|
|
|
begin
|
|
Timeout.timeout(timeout, &block)
|
|
rescue Timeout::Error => e
|
|
error_ttl = timeout_protection_error_ttl(service_name)
|
|
redis.incrby(redis_key, 1)
|
|
redis.expire(redis_key, error_ttl)
|
|
raise
|
|
end
|
|
end
|
|
|
|
def self.timeout_protection_failure_rate_cutoff(service_name)
|
|
(Setting.get("service_#{service_name}_failure_rate_cutoff", nil) ||
|
|
Setting.get("service_generic_failure_rate_cutoff", ".2")).to_f
|
|
end
|
|
|
|
def self.timeout_protection_failure_counter_window(service_name)
|
|
(Setting.get("service_#{service_name}_counter_window", nil) ||
|
|
Setting.get("service_generic_counter_window", 60.to_s)).to_i
|
|
end
|
|
|
|
def self.timeout_protection_failure_min_samples(service_name)
|
|
(Setting.get("service_#{service_name}_min_samples", nil) ||
|
|
Setting.get("service_generic_min_samples", 100.to_s)).to_i
|
|
end
|
|
|
|
def self.percent_short_circuit_timeout(redis, service_name, timeout, &block)
|
|
redis_key = "service:timeouts:#{service_name}:percent_counter"
|
|
cutoff = timeout_protection_failure_rate_cutoff(service_name)
|
|
|
|
protection_activated_key = "#{redis_key}:protection_activated"
|
|
protection_activated = redis.get(protection_activated_key)
|
|
raise TimeoutCutoff.new(cutoff) if protection_activated
|
|
|
|
counter_window = timeout_protection_failure_counter_window(service_name)
|
|
min_samples = timeout_protection_failure_min_samples(service_name)
|
|
counter = FailurePercentCounter.new(redis, redis_key, counter_window, min_samples)
|
|
|
|
failure_rate = counter.failure_rate
|
|
if failure_rate >= cutoff
|
|
# We add the key for timeout protection here, instead of in the
|
|
# error block below, because in a previous run, we could go over
|
|
# the minimum number of samples with a non-timedout call. This
|
|
# has the added benefit of making the error block below much
|
|
# smaller.
|
|
error_ttl = timeout_protection_error_ttl(service_name)
|
|
redis.set(protection_activated_key, "true")
|
|
redis.expire(protection_activated_key, error_ttl)
|
|
raise TimeoutCutoff.new(failure_rate)
|
|
end
|
|
|
|
begin
|
|
counter.increment_count
|
|
Timeout.timeout(timeout, &block)
|
|
rescue Timeout::Error
|
|
counter.increment_failure
|
|
raise
|
|
end
|
|
end
|
|
|
|
class TimeoutCutoff < Timeout::Error
|
|
attr_accessor :error_count
|
|
|
|
def initialize(error_count)
|
|
@error_count = error_count
|
|
end
|
|
end
|
|
|
|
def self.cluster
|
|
nil
|
|
end
|
|
|
|
def self.environment
|
|
Rails.env
|
|
end
|
|
end
|