rediconn 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6ca73adca8e6a1b981b52e05f3ba3eedc3ec1e4043c564128ad47646d9153014
4
+ data.tar.gz: 8a8582b2e4a53e6719bc49176d43524359bf8bba278c81506edd65cb35cb0204
5
+ SHA512:
6
+ metadata.gz: 712cb5abbb5efb2eece4ede5624f3d2660bce499c654fa6b2cd8e9780293298f59a3e9e1d130237502c6fb74547f643e2c41dcd8754c6c347da5c57f182e0160
7
+ data.tar.gz: 906d3a5416e33d164b7705b05512ee43e1932245c493692e3ace4169d091d1a6115aa079e10202166399dc352fb9e0da5763ee363978dfb3db872edb92071f07
data/README.md ADDED
@@ -0,0 +1 @@
1
+ # RediConn
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connection_pool"
4
+ require "redis"
5
+ require "uri"
6
+
7
+ # Adapted from Sidekiq 6.x Implementation
8
+
9
+ module RediConn
10
+ class RedisConnection < ConnectionPool
11
+ KNOWN_REDIS_URL_ENVS = %w[REDIS_URL REDISTOGO_URL REDISCLOUD_URL].freeze
12
+
13
+ class << self
14
+ def configured?(prefix, explicit: true)
15
+ determine_redis_provider(prefix: prefix, explicit: explicit).present?
16
+ end
17
+
18
+ def shared_pools
19
+ @shared_pools ||= {}
20
+ end
21
+
22
+ def create(raw_options = {})
23
+ options = raw_options.transform_keys(&:to_sym)
24
+
25
+ if !options[:url] && (u = determine_redis_provider(prefix: raw_options[:env_prefix]))
26
+ options[:url] = u
27
+ end
28
+
29
+ options[:timeout] ||= 1
30
+ options[:size] ||= if options[:env_prefix] && (v = ENV["#{options[:env_prefix]}_REDIS_POOL_SIZE"])
31
+ Integer(v)
32
+ elsif ENV["RAILS_MAX_THREADS"]
33
+ Integer(ENV["RAILS_MAX_THREADS"])
34
+ else
35
+ 5
36
+ end
37
+
38
+ if options[:dedicated]
39
+ initialize_pool(options)
40
+ else
41
+ url = options[:url]
42
+ cpool = shared_pools[url] ||= initialize_pool(options)
43
+
44
+ cpool.resize!(options[:size])
45
+
46
+ # TODO Would be nice to respect timeouts
47
+
48
+ cpool
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def client_opts(options)
55
+ opts = options.dup
56
+ if opts[:namespace]
57
+ opts.delete(:namespace)
58
+ end
59
+
60
+ opts.delete(:size)
61
+ opts.delete(:env_prefix)
62
+
63
+ if opts[:network_timeout]
64
+ opts[:timeout] = opts[:network_timeout]
65
+ opts.delete(:network_timeout)
66
+ end
67
+
68
+ opts[:reconnect_attempts] ||= 1
69
+
70
+ opts
71
+ end
72
+
73
+ def determine_redis_provider(prefix: nil, explicit: false)
74
+ vars = []
75
+
76
+ if prefix.present?
77
+ if (ptr = ENV["#{prefix}_REDIS_PROVIDER"]).present?
78
+ return ENV[ptr]
79
+ else
80
+ vars.push(*KNOWN_REDIS_URL_ENVS.map { |e| ENV["#{prefix}_#{e}"] })
81
+ end
82
+ end
83
+
84
+ if !explicit || !prefix.present?
85
+ if (ptr = ENV["REDIS_PROVIDER"]).present?
86
+ vars << ptr # Intentionally not a return
87
+ else
88
+ vars.push(*KNOWN_REDIS_URL_ENVS)
89
+ end
90
+ end
91
+
92
+ vars.select!(&:present?)
93
+
94
+ vars.each do |e|
95
+ return ENV[e] if ENV[e].present?
96
+ end
97
+
98
+ nil
99
+ end
100
+
101
+ def initialize_pool(options)
102
+ new(timeout: options[:timeout], size: options[:size]) do
103
+ namespace = options[:namespace]
104
+ client = Redis.new client_opts(options)
105
+
106
+ if namespace
107
+ begin
108
+ require "redis/namespace"
109
+ Redis::Namespace.new(namespace, redis: client)
110
+ rescue LoadError
111
+ Rails.logger.error("Your Redis configuration uses the namespace '#{namespace}' but the redis-namespace gem is not included in the Gemfile." \
112
+ "Add the gem to your Gemfile to continue using a namespace. Otherwise, remove the namespace parameter.")
113
+ exit(-127)
114
+ end
115
+ else
116
+ client
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ # Mainly intended for console use
123
+ def lazy
124
+ RedisProxy.new do |&blk|
125
+ with(&blk)
126
+ end
127
+ end
128
+
129
+ def lazy_with(&blk)
130
+ return lazy unless blk
131
+ with(&blk)
132
+ end
133
+
134
+ # Very naughty, but looking at the source, should be safe
135
+ def resize!(new_size)
136
+ return unless new_size > @size
137
+ @size = new_size
138
+ @available.instance_variable_set(:@max, new_size)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,67 @@
1
+ module RediConn
2
+ module RedisModel
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def redis_attr(key, type = :string, read_only: true)
7
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
8
+ def #{key}=(value)
9
+ raise "#{key} is read-only once the batch has been started" if #{read_only.to_s} && (@initialized || @existing)
10
+ @#{key} = value
11
+ if :#{type} == :json
12
+ value = JSON.unparse(value)
13
+ end
14
+ persist_bid_attr('#{key}', value)
15
+ end
16
+
17
+ def #{key}
18
+ return @#{key} if defined?(@#{key})
19
+ if (@initialized || @existing)
20
+ value = read_bid_attr('#{key}')
21
+ if :#{type} == :bool
22
+ value = value == 'true'
23
+ elsif :#{type} == :int
24
+ value = value.to_i
25
+ elsif :#{type} == :float
26
+ value = value.to_f
27
+ elsif :#{type} == :json
28
+ value = JSON.parse(value)
29
+ elsif :#{type} == :symbol
30
+ value = value&.to_sym
31
+ end
32
+ @#{key} = value
33
+ end
34
+ end
35
+ RUBY
36
+ end
37
+ end
38
+
39
+ def persist_bid_attr(attribute, value)
40
+ if @initialized || @existing
41
+ redis do |r|
42
+ r.multi do |r|
43
+ r.hset(redis_key, attribute, value.to_s)
44
+ r.expire(redis_key, Batch::BID_EXPIRE_TTL)
45
+ end
46
+ end
47
+ else
48
+ @pending_attrs ||= {}
49
+ @pending_attrs[attribute] = value.to_s
50
+ end
51
+ end
52
+
53
+ def read_bid_attr(attribute)
54
+ redis do |r|
55
+ r.hget(redis_key, attribute)
56
+ end
57
+ end
58
+
59
+ def flush_pending_attrs
60
+ redis do |r|
61
+ r.mapped_hmset(redis_key, @pending_attrs)
62
+ end
63
+ @initialized = true
64
+ @pending_attrs = {}
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,51 @@
1
+ module RediConn
2
+ # Mainly intended for console use
3
+ class RedisProxy
4
+ def initialize(&factory)
5
+ @factory = factory
6
+ end
7
+
8
+ def multi(*args, &block)
9
+ @factory.call do |r|
10
+ r.multi(*args) do |r|
11
+ block.call(r)
12
+ end
13
+ end
14
+ end
15
+
16
+ def pipelined(*args, &block)
17
+ @factory.call do |r|
18
+ r.pipelined(*args) do |r2|
19
+ block.call(r2 || r)
20
+ end
21
+ end
22
+ end
23
+
24
+ def uget(key)
25
+ @factory.call do |r|
26
+ case r.type(key)
27
+ when 'string'
28
+ r.get(key)
29
+ when 'list'
30
+ r.lrange(key, 0, -1)
31
+ when 'hash'
32
+ r.hgetall(key)
33
+ when 'set'
34
+ r.smembers(key)
35
+ when 'zset'
36
+ r.zrange(key, 0, -1)
37
+ end
38
+ end
39
+ end
40
+
41
+ def method_missing(method_name, *arguments, &block)
42
+ @factory.call do |r|
43
+ r.send(method_name, *arguments, &block)
44
+ end
45
+ end
46
+
47
+ def respond_to_missing?(method_name, include_private = false)
48
+ super || Redis.method_defined?(method_name)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,161 @@
1
+ require 'pathname'
2
+ require 'digest/sha1'
3
+ require 'erb'
4
+
5
+ # Modified from https://github.com/Shopify/wolverine/blob/master/lib/wolverine/script.rb
6
+
7
+ module RediConn
8
+ # {RedisScript} represents a lua script in the filesystem. It loads the script
9
+ # from disk and handles talking to redis to execute it. Error handling
10
+ # is handled by {LuaError}.
11
+ class RedisScript
12
+
13
+ # Loads the script file from disk and calculates its +SHA1+ sum.
14
+ #
15
+ # @param file [Pathname] the full path to the indicated file
16
+ def initialize(file)
17
+ @file = Pathname.new(file)
18
+ end
19
+
20
+ # Passes the script and supplied arguments to redis for evaulation.
21
+ # It first attempts to use a script redis has already cached by using
22
+ # the +EVALSHA+ command, but falls back to providing the full script
23
+ # text via +EVAL+ if redis has not seen this script before. Future
24
+ # invocations will then use +EVALSHA+ without erroring.
25
+ #
26
+ # @param redis [Redis] the redis connection to run against
27
+ # @param args [*Objects] the arguments to the script
28
+ # @return [Object] the value passed back by redis after script execution
29
+ # @raise [LuaError] if the script failed to compile of encountered a
30
+ # runtime error
31
+ def call(redis, *args)
32
+ t = Time.now
33
+ begin
34
+ redis.evalsha(digest, *args)
35
+ rescue => e
36
+ e.message =~ /NOSCRIPT/ ? redis.eval(content, *args) : raise
37
+ end
38
+ rescue => e
39
+ if LuaError.intercepts?(e)
40
+ raise LuaError.new(e, @file, content)
41
+ else
42
+ raise
43
+ end
44
+ end
45
+
46
+ def content
47
+ @content ||= load_lua(@file)
48
+ end
49
+
50
+ def digest
51
+ @digest ||= Digest::SHA1.hexdigest content
52
+ end
53
+
54
+ private
55
+
56
+ def script_path
57
+ Rails.root + 'app/redis_lua'
58
+ end
59
+
60
+ def relative_path
61
+ @path ||= @file.relative_path_from(script_path)
62
+ end
63
+
64
+ def load_lua(file)
65
+ TemplateContext.new(script_path).template(script_path + file)
66
+ end
67
+
68
+ class TemplateContext
69
+ def initialize(script_path)
70
+ @script_path = script_path
71
+ end
72
+
73
+ def template(pathname)
74
+ @partial_templates ||= {}
75
+ ERB.new(File.read(pathname)).result binding
76
+ end
77
+
78
+ # helper method to include a lua partial within another lua script
79
+ #
80
+ # @param relative_path [String] the relative path to the script from
81
+ # `script_path`
82
+ def include_partial(relative_path)
83
+ unless @partial_templates.has_key? relative_path
84
+ @partial_templates[relative_path] = nil
85
+ template( Pathname.new("#{@script_path}/#{relative_path}") )
86
+ end
87
+ end
88
+ end
89
+
90
+ # Reformats errors raised by redis representing failures while executing
91
+ # a lua script. The default errors have confusing messages and backtraces,
92
+ # and a type of +RuntimeError+. This class improves the message and
93
+ # modifies the backtrace to include the lua script itself in a reasonable
94
+ # way.
95
+ class LuaError < StandardError
96
+ PATTERN = /ERR Error (compiling|running) script \(.*?\): .*?:(\d+): (.*)/
97
+ WOLVERINE_LIB_PATH = File.expand_path('../../', __FILE__)
98
+ CONTEXT_LINE_NUMBER = 2
99
+
100
+ attr_reader :error, :file, :content
101
+
102
+ # Is this error one that should be reformatted?
103
+ #
104
+ # @param error [StandardError] the original error raised by redis
105
+ # @return [Boolean] is this an error that should be reformatted?
106
+ def self.intercepts? error
107
+ error.message =~ PATTERN
108
+ end
109
+
110
+ # Initialize a new {LuaError} from an existing redis error, adjusting
111
+ # the message and backtrace in the process.
112
+ #
113
+ # @param error [StandardError] the original error raised by redis
114
+ # @param file [Pathname] full path to the lua file the error ocurred in
115
+ # @param content [String] lua file content the error ocurred in
116
+ def initialize error, file, content
117
+ @error = error
118
+ @file = file
119
+ @content = content
120
+
121
+ @error.message =~ PATTERN
122
+ _stage, line_number, message = $1, $2, $3
123
+ error_context = generate_error_context(content, line_number.to_i)
124
+
125
+ super "#{message}\n\n#{error_context}\n\n"
126
+ set_backtrace generate_backtrace file, line_number
127
+ end
128
+
129
+ private
130
+
131
+ def generate_error_context(content, line_number)
132
+ lines = content.lines.to_a
133
+ beginning_line_number = [1, line_number - CONTEXT_LINE_NUMBER].max
134
+ ending_line_number = [lines.count, line_number + CONTEXT_LINE_NUMBER].min
135
+ line_number_width = ending_line_number.to_s.length
136
+
137
+ (beginning_line_number..ending_line_number).map do |number|
138
+ indicator = number == line_number ? '=>' : ' '
139
+ formatted_number = "%#{line_number_width}d" % number
140
+ " #{indicator} #{formatted_number}: #{lines[number - 1]}"
141
+ end.join.chomp
142
+ end
143
+
144
+ def generate_backtrace(file, line_number)
145
+ pre_wolverine = backtrace_before_entering_wolverine(@error.backtrace)
146
+ index_of_first_wolverine_line = (@error.backtrace.size - pre_wolverine.size - 1)
147
+ pre_wolverine.unshift(@error.backtrace[index_of_first_wolverine_line])
148
+ pre_wolverine.unshift("#{file}:#{line_number}")
149
+ pre_wolverine
150
+ end
151
+
152
+ def backtrace_before_entering_wolverine(backtrace)
153
+ backtrace.reverse.take_while { |line| ! line_from_wolverine(line) }.reverse
154
+ end
155
+
156
+ def line_from_wolverine(line)
157
+ line.split(':').first.include?(WOLVERINE_LIB_PATH)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,3 @@
1
+ module RediConn
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/rediconn.rb ADDED
@@ -0,0 +1,8 @@
1
+
2
+ require 'active_support/concern'
3
+
4
+ Dir[File.dirname(__FILE__) + "/rediconn/**/*.rb"].each { |file| require file }
5
+
6
+ module RediConn
7
+
8
+ end
data/rediconn.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ begin
6
+ require "rediconn/version"
7
+ version = RediConn::VERSION
8
+ rescue LoadError
9
+ version = "0.0.0.docker"
10
+ end
11
+
12
+ Gem::Specification.new do |spec|
13
+ spec.name = "rediconn"
14
+ spec.version = version
15
+ spec.authors = ["Ethan Knapp"]
16
+ spec.email = ["eknapp@instructure.com"]
17
+
18
+ spec.summary = "Gem for consistent, de-duplicated Redis Connection Pooling"
19
+ spec.homepage = "https://instructure.com"
20
+
21
+ spec.files = Dir["{app,config,db,lib}/**/*", "README.md", "*.gemspec"]
22
+ spec.test_files = Dir["spec/**/*"]
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_dependency "rails", ">= 5.2", "< 9.0"
26
+ spec.add_dependency "connection_pool", ">= 2.2.0", "< 3.0"
27
+
28
+ spec.add_development_dependency "redis"
29
+ spec.add_development_dependency 'rspec', '~> 3'
30
+ end
@@ -0,0 +1,38 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe RediConn::RedisConnection do
4
+ let(:conn) { described_class.create(env_prefix: "REDICONN") }
5
+
6
+ describe '.redis' do
7
+ it 'returns the same connection if called when nested' do
8
+ conn.lazy_with do |r1|
9
+ conn.lazy_with do |r2|
10
+ expect(r1).to be r2
11
+ end
12
+ end
13
+ end
14
+
15
+ it 'returns a RedisProxy object if no block is given' do
16
+ expect(conn.lazy).to be_a(RediConn::RedisProxy)
17
+ end
18
+
19
+ describe 'RedisProxy' do
20
+ let!(:proxy) { conn.lazy }
21
+
22
+ it 'calls Batch.redis with a block' do
23
+ expect(conn).to receive(:with) do |&block|
24
+ expect(block).to be_a Proc
25
+ end
26
+ proxy.sadd('KEY', 5)
27
+ end
28
+
29
+ describe '#multi' do
30
+ it 'works with 1 arity' do
31
+ proxy.multi do |r|
32
+ r.sadd('KEY', 5)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ require 'bundler'
2
+
3
+ Bundler.require :default, :development
4
+
5
+ require 'active_support'
6
+ require 'active_support/core_ext'
7
+
8
+ require 'redis'
9
+
10
+ RSpec.configure do |config|
11
+
12
+ end
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rediconn
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ethan Knapp
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '5.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: connection_pool
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 2.2.0
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 2.2.0
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: redis
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ type: :development
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: rspec
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '3'
73
+ type: :development
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '3'
80
+ email:
81
+ - eknapp@instructure.com
82
+ executables: []
83
+ extensions: []
84
+ extra_rdoc_files: []
85
+ files:
86
+ - README.md
87
+ - lib/rediconn.rb
88
+ - lib/rediconn/redis_connection.rb
89
+ - lib/rediconn/redis_model.rb
90
+ - lib/rediconn/redis_proxy.rb
91
+ - lib/rediconn/redis_script.rb
92
+ - lib/rediconn/version.rb
93
+ - rediconn.gemspec
94
+ - spec/rediconn/redis_connection_spec.rb
95
+ - spec/spec_helper.rb
96
+ homepage: https://instructure.com
97
+ licenses: []
98
+ metadata: {}
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.6.9
114
+ specification_version: 4
115
+ summary: Gem for consistent, de-duplicated Redis Connection Pooling
116
+ test_files:
117
+ - spec/rediconn/redis_connection_spec.rb
118
+ - spec/spec_helper.rb