require 'pathname' require 'digest/sha1' require 'erb' # Modified from https://github.com/Shopify/wolverine/blob/master/lib/wolverine/script.rb module Bearcat module RateLimiting # {RedisScript} represents a lua script in the filesystem. It loads the script # from disk and handles talking to redis to execute it. Error handling # is handled by {LuaError}. class RedisScript # Loads the script file from disk and calculates its +SHA1+ sum. # # @param file [Pathname] the full path to the indicated file def initialize(file) @file = Pathname.new(file) end # Passes the script and supplied arguments to redis for evaulation. # It first attempts to use a script redis has already cached by using # the +EVALSHA+ command, but falls back to providing the full script # text via +EVAL+ if redis has not seen this script before. Future # invocations will then use +EVALSHA+ without erroring. # # @param redis [Redis] the redis connection to run against # @param args [*Objects] the arguments to the script # @return [Object] the value passed back by redis after script execution # @raise [LuaError] if the script failed to compile of encountered a # runtime error def call(redis, *args) t = Time.now begin redis.evalsha(digest, *args) rescue => e e.message =~ /NOSCRIPT/ ? redis.eval(content, *args) : raise end rescue => e if LuaError.intercepts?(e) raise LuaError.new(e, @file, content) else raise end end def content @content ||= load_lua(@file) end def digest @digest ||= Digest::SHA1.hexdigest content end private def script_path @file.basename # Rails.root + 'app/redis_lua' end def relative_path @path ||= @file.relative_path_from(script_path) end def load_lua(file) TemplateContext.new(script_path).template(script_path + file) end class TemplateContext def initialize(script_path) @script_path = script_path end def template(pathname) @partial_templates ||= {} ERB.new(File.read(pathname)).result binding end # helper method to include a lua partial within another lua script # # @param relative_path [String] the relative path to the script from # `script_path` def include_partial(relative_path) unless @partial_templates.has_key? relative_path @partial_templates[relative_path] = nil template( Pathname.new("#{@script_path}/#{relative_path}") ) end end end # Reformats errors raised by redis representing failures while executing # a lua script. The default errors have confusing messages and backtraces, # and a type of +RuntimeError+. This class improves the message and # modifies the backtrace to include the lua script itself in a reasonable # way. class LuaError < StandardError PATTERN = /ERR Error (compiling|running) script \(.*?\): .*?:(\d+): (.*)/ WOLVERINE_LIB_PATH = File.expand_path('../../', __FILE__) CONTEXT_LINE_NUMBER = 2 attr_reader :error, :file, :content # Is this error one that should be reformatted? # # @param error [StandardError] the original error raised by redis # @return [Boolean] is this an error that should be reformatted? def self.intercepts? error error.message =~ PATTERN end # Initialize a new {LuaError} from an existing redis error, adjusting # the message and backtrace in the process. # # @param error [StandardError] the original error raised by redis # @param file [Pathname] full path to the lua file the error ocurred in # @param content [String] lua file content the error ocurred in def initialize error, file, content @error = error @file = file @content = content @error.message =~ PATTERN _stage, line_number, message = $1, $2, $3 error_context = generate_error_context(content, line_number.to_i) super "#{message}\n\n#{error_context}\n\n" set_backtrace generate_backtrace file, line_number end private def generate_error_context(content, line_number) lines = content.lines.to_a beginning_line_number = [1, line_number - CONTEXT_LINE_NUMBER].max ending_line_number = [lines.count, line_number + CONTEXT_LINE_NUMBER].min line_number_width = ending_line_number.to_s.length (beginning_line_number..ending_line_number).map do |number| indicator = number == line_number ? '=>' : ' ' formatted_number = "%#{line_number_width}d" % number " #{indicator} #{formatted_number}: #{lines[number - 1]}" end.join.chomp end def generate_backtrace(file, line_number) pre_wolverine = backtrace_before_entering_wolverine(@error.backtrace) index_of_first_wolverine_line = (@error.backtrace.size - pre_wolverine.size - 1) pre_wolverine.unshift(@error.backtrace[index_of_first_wolverine_line]) pre_wolverine.unshift("#{file}:#{line_number}") pre_wolverine end def backtrace_before_entering_wolverine(backtrace) backtrace.reverse.take_while { |line| ! line_from_wolverine(line) }.reverse end def line_from_wolverine(line) line.split(':').first.include?(WOLVERINE_LIB_PATH) end end end end end