multi_dbs_load_balancer 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 599c6e5a768320312bf00d07478354a250bd3f167b0dfc08071708864d6e0513
4
+ data.tar.gz: d0462ea28e57473be4aa5af57e9ca80359f2c3c0715ebffe02f60a400cd9f017
5
+ SHA512:
6
+ metadata.gz: 824e76ac8571b4499720c6aecc38b9987fb8550fd12e40610a8388bf907f85c572394a5bea86baec8433ce13c346cd4ff0b8fccad43af3d50c959106c4fb87c9
7
+ data.tar.gz: a86aef07d4fca8c20083193657cb4fa4e3f7727e69eb8cbfb0c5e7d47741873374aab566e3293994107cc398e3a13387ac3dc440242acccec0fdcf3dd2603bb8
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 theforestvn88
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # MultiDbsLoadBalancer
2
+
3
+ Allow to setup load balancers sit on top of [rails multi-databases](https://guides.rubyonrails.org/active_record_multiple_databases.html).
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem "multi_dbs_load_balancer"
9
+
10
+ $ bundle install
11
+ $ rails g multi_dbs_load_balancer:install
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Declaring load balancers
17
+ ```ruby
18
+ # config/initializers/multi_dbs_load_balancer.rb
19
+ load_balancer.db_down_time = 120
20
+ load_balancer.redis_down_time = 120
21
+ load_balancer.init :rr_load_balancer,
22
+ [
23
+ {role: :reading1},
24
+ {role: :reading2},
25
+ {role: :reading3},
26
+ ],
27
+ algorithm: :round_robin,
28
+ redis: Redis.new(...)
29
+ ```
30
+
31
+ Now you could use them on controllers/services ...
32
+ ```ruby
33
+ # products_controller.rb
34
+ def index
35
+ @products = ActiveRecord::Base.connected_through(:rr_load_balancer) { Product.all }
36
+ # alias methods: connected_by, connected_through_load_balancer
37
+ end
38
+ ```
39
+
40
+ You could also create and use a Middleware to wrap load balancer base on the request, for example:
41
+ ```ruby
42
+ class LoadBalancerMiddleware
43
+ def initialize(app)
44
+ @app = app
45
+ end
46
+
47
+ def call(env)
48
+ request = ActionDispatch::Request.new(env)
49
+ if is_something?(request)
50
+ ActiveRecord::Base.connected_through(:rr_load_balancer) do
51
+ @app.call(env)
52
+ end
53
+ else
54
+ @app.call(env)
55
+ end
56
+ end
57
+
58
+ private def is_something?(request)
59
+ # for example: check if reading request
60
+ request.get? || request.head?
61
+ end
62
+ end
63
+
64
+ Rails.application.config.app_middleware.use LoadBalancerMiddleware
65
+ ```
66
+
67
+ ## Notes
68
+
69
+ - Support algorithms: `round_robin`, `weight_round_robin`, `least_connection`, `least_response_time`, `hash`, `randomized`
70
+
71
+ - Distribute
72
+
73
+ If you launch multiple servers then you wish your load balancers will share states between servers,
74
+ there're 3 algorithms that will do that if you provide a redis server:
75
+
76
+ + `round_robin` will share the current database
77
+
78
+ + `least_connection` and `least_response_time` will share the sorted list of databases
79
+
80
+ Other algorithms are independent on each server, so you don't need to provide a redis server for them.
81
+
82
+ - Fail-over
83
+
84
+ All load balancers here are passive, they don't track database connections or redis connections.
85
+
86
+ Whenever it could not connect to a database, it mark that database have down for `db_down_time` seconds and ignore it on the next round,
87
+ and try to connect to the next available database.
88
+
89
+ After `db_down_time` seconds, the load balancer will try to connect this database again.
90
+
91
+ Whenever the redis-server has down (or you dont setup redis), distribute load balancers will process offline on each server until redis come back.
92
+
93
+
94
+
95
+ ## Development
96
+
97
+ run test
98
+ ```ruby
99
+ rake setup_db
100
+ rake spec
101
+ ```
102
+
103
+
104
+ ## Contributing
105
+
106
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/rails_dbs_load_balancer.
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ task :setup_db do
7
+ p "setup db ..."
8
+ `sqlite3 spec/dummy/db/primary.sqlite3 'CREATE TABLE IF NOT EXISTS developers (name VARCHAR (255))'`
9
+ `sqlite3 spec/dummy/db/primary_replica1.sqlite3 'CREATE TABLE IF NOT EXISTS developers (name VARCHAR (255))'`
10
+ `sqlite3 spec/dummy/db/primary_replica2.sqlite3 'CREATE TABLE IF NOT EXISTS developers (name VARCHAR (255))'`
11
+ `sqlite3 spec/dummy/db/primary_replica3.sqlite3 'CREATE TABLE IF NOT EXISTS developers (name VARCHAR (255))'`
12
+ `sqlite3 spec/dummy/db/primary_replica4.sqlite3 'CREATE TABLE IF NOT EXISTS developers (name VARCHAR (255))'`
13
+ `sqlite3 spec/dummy/db/primary_replica5.sqlite3 'CREATE TABLE IF NOT EXISTS developers (name VARCHAR (255))'`
14
+ `sqlite3 spec/dummy/db/primary_replica6.sqlite3 'CREATE TABLE IF NOT EXISTS developers (name VARCHAR (255))'`
15
+ end
16
+
17
+ RSpec::Core::RakeTask.new(:spec)
18
+
19
+ task default: [:setup_db, :spec]
data/benchmark.rb ADDED
@@ -0,0 +1,93 @@
1
+ require 'benchmark/ips'
2
+ require_relative "./lib/multi_dbs_load_balancer"
3
+ require_relative "./spec/dummy/models/developer"
4
+
5
+ Benchmark.ips do |bm|
6
+ bm.report("ActiveRecord#connected_to") do
7
+ threads = []
8
+ [:reading1, :reading2, :reading3, :reading4, :reading5, :reading6].each do |db|
9
+ 10.times do |i|
10
+ threads << Thread.new do
11
+ ActiveRecord::Base.connected_to(role: db) do
12
+ result = Developer.all
13
+ end
14
+ end
15
+ end
16
+ end
17
+ threads.map(&:join)
18
+ end
19
+
20
+ bm.report("connect through round robin load balancer") do
21
+ threads = []
22
+ 60.times do |i|
23
+ threads << Thread.new do
24
+ Developer.connected_through_load_balancer(:rr) do
25
+ result = Developer.all
26
+ end
27
+ end
28
+ end
29
+ threads.map(&:join)
30
+ end
31
+
32
+ bm.report("connect through least connection load balancer") do
33
+ threads = []
34
+ 60.times do |i|
35
+ threads << Thread.new do
36
+ Developer.connected_through_load_balancer(:lc) do
37
+ result = Developer.all
38
+ end
39
+ end
40
+ end
41
+ threads.map(&:join)
42
+ end
43
+
44
+ bm.report("connect through least response time load balancer") do
45
+ threads = []
46
+ 60.times do |i|
47
+ threads << Thread.new do
48
+ Developer.connected_through_load_balancer(:lrt) do
49
+ result = Developer.all
50
+ end
51
+ end
52
+ end
53
+ threads.map(&:join)
54
+ end
55
+
56
+ bm.report("connect through weight round robin load balancer") do
57
+ threads = []
58
+ 60.times do |i|
59
+ threads << Thread.new do
60
+ Developer.connected_through_load_balancer(:wrr) do
61
+ result = Developer.all
62
+ end
63
+ end
64
+ end
65
+ threads.map(&:join)
66
+ end
67
+
68
+ bm.report("connect through hash load balancer") do
69
+ threads = []
70
+ 60.times do |i|
71
+ threads << Thread.new do
72
+ Developer.connected_through_load_balancer(:hash, source: "197.168.1.1") do
73
+ result = Developer.all
74
+ end
75
+ end
76
+ end
77
+ threads.map(&:join)
78
+ end
79
+
80
+ bm.report("connect through randomized load balancer") do
81
+ threads = []
82
+ 60.times do |i|
83
+ threads << Thread.new do
84
+ Developer.connected_through_load_balancer(:randomized) do
85
+ result = Developer.all
86
+ end
87
+ end
88
+ end
89
+ threads.map(&:join)
90
+ end
91
+
92
+ bm.compare!
93
+ end
@@ -0,0 +1,53 @@
1
+ class DistributeLock
2
+ include RedisLua
3
+ attr_reader :redis
4
+
5
+ def initialize(redis)
6
+ @redis = redis
7
+ end
8
+
9
+ def synchronize(name, lock_time = 3600)
10
+ return yield if @redis.nil?
11
+
12
+ lock_name = "#{name}:lock"
13
+ time_lock = eval_lua_script(LOCK_SCRIPT, LOCK_SCRIPT_SHA1, [lock_name], [lock_time])
14
+ return if time_lock.nil?
15
+
16
+ begin
17
+ yield
18
+ ensure
19
+ eval_lua_script(UNLOCK_SCRIPT, UNLOCK_SCRIPT_SHA1, [name], [time_lock.to_s])
20
+ end
21
+ rescue
22
+ yield
23
+ end
24
+
25
+ private
26
+
27
+ LOCK_SCRIPT = <<~LUA
28
+ local now = redis.call("time")[1]
29
+ local expire_time = now + ARGV[1]
30
+ local current_expire_time = redis.call("get", KEYS[1])
31
+
32
+ if current_expire_time and tonumber(now) <= tonumber(current_expire_time) then
33
+ return nil
34
+ else
35
+ local result = redis.call("setex", KEYS[1], ARGV[1] + 1, tostring(expire_time))
36
+ return expire_time
37
+ end
38
+ LUA
39
+ LOCK_SCRIPT_SHA1 = Digest::SHA1.hexdigest LOCK_SCRIPT
40
+
41
+ UNLOCK_SCRIPT = <<~LUA
42
+ local current_expire_time = redis.call("get", KEYS[1])
43
+
44
+ if current_expire_time == ARGV[1] then
45
+ local result = redis.call("del", KEYS[1])
46
+ return result ~= nil
47
+ else
48
+ return false
49
+ end
50
+ LUA
51
+ UNLOCK_SCRIPT_SHA1 = Digest::SHA1.hexdigest UNLOCK_SCRIPT
52
+
53
+ end
@@ -0,0 +1,36 @@
1
+ module Healthcheck
2
+ cattr_reader :db_down_times, :redis_down_times
3
+
4
+ def mark_redis_down
5
+ set_redis_down_time(Time.now.to_i + LoadBalancer.redis_down_time)
6
+ end
7
+
8
+ def redis_available?
9
+ @redis.present? && redis_down_time < Time.now.to_i
10
+ end
11
+
12
+ def mark_db_down(db_index)
13
+ db_down_times[db_index] = Time.now.to_i + LoadBalancer.db_down_time
14
+ end
15
+
16
+ def db_available?(db_index)
17
+ db_down_times[db_index] < Time.now.to_i
18
+ end
19
+
20
+ private def db_down_times
21
+ @@db_down_times ||= {}
22
+ @@db_down_times["#{@key}:dt"] ||= Hash.new(0)
23
+ end
24
+
25
+ private def redis_down_times
26
+ @@redis_down_times ||= Hash.new(0)
27
+ end
28
+
29
+ private def redis_down_time
30
+ redis_down_times["#{@key}:dt"]
31
+ end
32
+
33
+ private def set_redis_down_time(t)
34
+ redis_down_times["#{@key}:dt"] = t
35
+ end
36
+ end
@@ -0,0 +1,64 @@
1
+ module LoadBalancer
2
+ class Algo
3
+ include RedisLua
4
+ include Healthcheck
5
+ attr_reader :database_configs, :redis, :key
6
+
7
+ def initialize(database_configs, redis:, key:)
8
+ @database_configs = database_configs
9
+ @redis = redis
10
+ @key = key
11
+ end
12
+
13
+ def warm_up
14
+ end
15
+
16
+ def next_db(**options)
17
+ raise NotImplementedError, ""
18
+ end
19
+
20
+ def after_connected
21
+ end
22
+
23
+ def after_executed
24
+ end
25
+
26
+ def connected_to_next_db(**options, &blk)
27
+ candidate_db, db_index = next_db(**options)
28
+ raise LoadBalancer::AllDatabasesHaveDown if candidate_db.nil?
29
+
30
+ if options.has_key?(:bases)
31
+ ::ActiveRecord::Base.connected_to_many(*options[:bases], **candidate_db.slice(:shard, :role), prevent_writes: options[:prevent_writes]) do
32
+ after_connected
33
+ blk.call
34
+ end
35
+ else
36
+ ::ActiveRecord::Base.connected_to(**candidate_db.slice(:shard, :role), prevent_writes: options[:prevent_writes]) do
37
+ after_connected
38
+ blk.call
39
+ end
40
+ end
41
+ rescue ActiveRecord::ConnectionNotEstablished
42
+ mark_db_down(db_index)
43
+ @should_retry = true
44
+ ensure
45
+ after_executed
46
+ if @should_retry
47
+ @should_retry = false
48
+ connected_to_next_db(**options, &blk)
49
+ end
50
+ end
51
+
52
+ def fail_over(next_choices)
53
+ candidate = next_choices.find do |i|
54
+ db_available?(i)
55
+ end
56
+
57
+ if candidate
58
+ [@database_configs[candidate], candidate]
59
+ else
60
+ [nil, -1]
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,3 @@
1
+ module LoadBalancer
2
+ class AllDatabasesHaveDown < StandardError; end
3
+ end
@@ -0,0 +1,37 @@
1
+ require_relative "./algo"
2
+
3
+ module LoadBalancer
4
+ class Hash < Algo
5
+ def next_db(**options)
6
+ db_index = hash_to_index(**options)
7
+ return @database_configs[db_index], db_index if db_available?(db_index)
8
+
9
+ # fail over
10
+ next_dbs = (db_index+1...db_index+@database_configs.size).map { |i| i % @database_configs.size }
11
+ fail_over(next_dbs)
12
+ end
13
+
14
+ private
15
+
16
+ def hash_to_index(**options)
17
+ rand(0...@database_configs.size) if options[:source].nil?
18
+
19
+ h = options[:hash_func]&.respond_to?(:call) ? options[:hash_func].call(options[:source]) : hashcode(options[:source])
20
+ h % @database_configs.size
21
+ end
22
+
23
+ def hashcode(source)
24
+ # NOTE:
25
+ # From Ruby 2.0 initialize MurmurHash using a random seed value which is reinitialized each time you restart Ruby.
26
+ # So `source.hash` will not be deterministic across servers.
27
+ # Therefore, this method uses java hashcode algorithm (Apache Harmony) instead.
28
+ h = 0
29
+ multipler = 1
30
+ (source.size-1).downto(0) do |i|
31
+ h = source[i].ord * multipler
32
+ multipler = (multipler << 5) - multipler
33
+ end
34
+ h
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,114 @@
1
+ require_relative "../min_heap"
2
+
3
+ module LoadBalancer
4
+ class LeastConnection < Algo
5
+ cattr_reader :mutexes, default: {}
6
+ cattr_reader :leasts, default: {}
7
+
8
+ def warm_up
9
+ pq_mutex.synchronize do
10
+ begin
11
+ unless @redis.exists?(db_conns_pq_key)
12
+ @database_configs.size.times do |i|
13
+ @redis.zincrby(db_conns_pq_key, 1, i)
14
+ end
15
+ end
16
+ rescue => e
17
+ # p e
18
+ ensure
19
+ return if @@leasts.has_key?(db_conns_pq_key)
20
+
21
+ @@leasts[db_conns_pq_key] = MinHeap.new
22
+ @database_configs.size.times do |i|
23
+ @@leasts[db_conns_pq_key].push([1, i])
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def next_db(**options)
30
+ @least_db_index = top_least
31
+ return @database_configs[@least_db_index], @least_db_index if db_available?(@least_db_index)
32
+
33
+ # fail over
34
+ fail_over(next_dbs)
35
+ end
36
+
37
+ def after_connected
38
+ increase
39
+ end
40
+
41
+ def after_executed
42
+ decrease
43
+ end
44
+
45
+ private
46
+
47
+ # CAS_TOP_SCRIPT = <<~LUA
48
+ # local top = unpack(redis.call("zrange", KEYS[1], 0, 0))
49
+ # redis.call("zincrby", KEYS[1], 1, top)
50
+ # return top
51
+ # LUA
52
+ # CAS_TOP_SCRIPT_SHA1 = ::Digest::SHA1.hexdigest CAS_TOP_SCRIPT
53
+
54
+ def db_conns_pq_key
55
+ "#{@key}:lc:pq"
56
+ end
57
+
58
+ def pq_mutex
59
+ @@mutexes[db_conns_pq_key] ||= Mutex.new
60
+ end
61
+
62
+ def top_least
63
+ return local_least(:extract) unless redis_available?
64
+ # eval_lua_script(CAS_TOP_SCRIPT, CAS_TOP_SCRIPT_SHA1, [db_conns_pq_key], [])
65
+ @redis.zrange(db_conns_pq_key, 0, 0).first.to_i
66
+ rescue => error
67
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
68
+ local_least(:extract)
69
+ end
70
+
71
+ def local_least(action = :extract)
72
+ pq_mutex.synchronize do
73
+ case action
74
+ when :extract
75
+ count, x = @@leasts[db_conns_pq_key].peak
76
+ x
77
+ when :decrease
78
+ @@leasts[db_conns_pq_key].decrease(@least_db_index, 1) if @least_db_index
79
+ when :increase
80
+ @@leasts[db_conns_pq_key].increase(@least_db_index, 1) if @least_db_index
81
+ end
82
+ end
83
+ end
84
+
85
+ def decrease
86
+ return unless @least_db_index
87
+ return local_least(:decrease) unless redis_available?
88
+
89
+ @redis.zincrby(db_conns_pq_key, -1, @least_db_index)
90
+ rescue => error
91
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
92
+ local_least(:decrease)
93
+ end
94
+
95
+ def increase
96
+ return unless @least_db_index
97
+ return local_least(:increase) unless redis_available?
98
+
99
+ @redis.zincrby(db_conns_pq_key, 1, @least_db_index)
100
+ rescue => error
101
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
102
+ local_least(:increase)
103
+ end
104
+
105
+ def next_dbs
106
+ return @@leasts[db_conns_pq_key].order unless redis_available?
107
+
108
+ @redis.zrange(db_conns_pq_key, 0, -1).map(&:to_i)
109
+ rescue => error
110
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
111
+ @@leasts[db_conns_pq_key].order
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,101 @@
1
+ require_relative "../min_heap"
2
+
3
+ module LoadBalancer
4
+ class LeastResponseTime < Algo
5
+ cattr_reader :mutexes, default: {}
6
+ cattr_reader :response_times, default: {}
7
+
8
+ def warm_up
9
+ pq_mutex.synchronize do
10
+ begin
11
+ unless @redis.exists?(db_conns_pq_key)
12
+ @database_configs.size.times do |i|
13
+ @redis.zincrby(db_conns_pq_key, 0, i)
14
+ end
15
+ end
16
+ rescue => error
17
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
18
+ ensure
19
+ return if @@response_times.has_key?(db_conns_pq_key)
20
+
21
+ @@response_times[db_conns_pq_key] = MinHeap.new
22
+ @database_configs.size.times do |i|
23
+ @@response_times[db_conns_pq_key].push([0, i])
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def next_db(**options)
30
+ @least_db_index = top_least.to_i
31
+ return @database_configs[@least_db_index], @least_db_index if db_available?(@least_db_index)
32
+
33
+ # fail over
34
+ fail_over(next_dbs)
35
+ end
36
+
37
+ def after_connected
38
+ @start_time = Time.now
39
+ end
40
+
41
+ def after_executed
42
+ update_response_time(Time.now - @start_time) if @start_time
43
+ end
44
+
45
+ private
46
+
47
+ CAS_TOP_SCRIPT = <<~LUA
48
+ local top = unpack(redis.call("zrange", KEYS[1], 0, 0))
49
+ redis.call("zincrby", KEYS[1], 0.1, top)
50
+ return top
51
+ LUA
52
+ CAS_TOP_SCRIPT_SHA1 = ::Digest::SHA1.hexdigest CAS_TOP_SCRIPT
53
+
54
+ def db_conns_pq_key
55
+ "#{@key}:lrt:pq"
56
+ end
57
+
58
+ def pq_mutex
59
+ @@mutexes[db_conns_pq_key] ||= Mutex.new
60
+ end
61
+
62
+ def top_least
63
+ return local_least unless redis_available?
64
+
65
+ eval_lua_script(CAS_TOP_SCRIPT, CAS_TOP_SCRIPT_SHA1, [db_conns_pq_key], [])
66
+ rescue => error
67
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
68
+ local_least
69
+ end
70
+
71
+ def local_least(action = :extract, **options)
72
+ pq_mutex.synchronize do
73
+ case action
74
+ when :extract
75
+ t, x = @@response_times[db_conns_pq_key].peak
76
+ x
77
+ when :update_response_time
78
+ @@response_times[db_conns_pq_key].replace(@least_db_index, options[:time]) if @least_db_index
79
+ end
80
+ end
81
+ end
82
+
83
+ def update_response_time(t)
84
+ return local_least(:update_response_time, time: t) unless redis_available?
85
+
86
+ @redis.zadd(db_conns_pq_key, t, @least_db_index)
87
+ rescue => error
88
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
89
+ local_least(:update_response_time, time: t)
90
+ end
91
+
92
+ def next_dbs
93
+ return @@response_times[db_conns_pq_key].order unless redis_available?
94
+
95
+ @redis.zrange(db_conns_pq_key, 0, -1).map(&:to_i)
96
+ rescue => error
97
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
98
+ @@response_times[db_conns_pq_key].order
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "./algo"
2
+
3
+ module LoadBalancer
4
+ class Randomized < Algo
5
+ def next_db(**options)
6
+ r = random_index
7
+ return @database_configs[r], r if db_available?(r)
8
+
9
+ next_dbs = (r+1...r+@database_configs.size).map { |i| i % @database_configs.size }
10
+ fail_over(next_dbs)
11
+ end
12
+
13
+ private
14
+
15
+ def random_index
16
+ rand(0...@database_configs.size)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "./algo"
2
+
3
+ module LoadBalancer
4
+ class RoundRobin < Algo
5
+ cattr_accessor :currents
6
+
7
+ def warm_up
8
+ @@currents ||= ::Hash.new(0)
9
+ end
10
+
11
+ def next_db(**options)
12
+ @current = cas_current
13
+ return @database_configs[@current], @current if db_available?(@current)
14
+
15
+ next_dbs = (@current+1...@current+@database_configs.size).map { |i| i % @database_configs.size }
16
+ fail_over(next_dbs)
17
+ end
18
+
19
+ private
20
+
21
+ CAS_NEXT_SCRIPT = <<~LUA
22
+ local curr = redis.call("get", KEYS[1])
23
+ if curr then
24
+ local next = (tonumber(curr) + 1) % ARGV[1]
25
+ redis.call("set", KEYS[1], next)
26
+ return next
27
+ else
28
+ redis.call("set", KEYS[1], 0)
29
+ return 0
30
+ end
31
+ LUA
32
+ CAS_NEXT_SCRIPT_SHA1 = ::Digest::SHA1.hexdigest CAS_NEXT_SCRIPT
33
+
34
+ def current_cached_key
35
+ "#{@key}:rr:current"
36
+ end
37
+
38
+ def cas_current
39
+ return local_current unless redis_available?
40
+ eval_lua_script(CAS_NEXT_SCRIPT, CAS_NEXT_SCRIPT_SHA1, [current_cached_key], [@database_configs.size])
41
+ rescue => error
42
+ # in case of redis failed
43
+ mark_redis_down if error.is_a?(Redis::CannotConnectError)
44
+ # random local server current
45
+ @@currents[current_cached_key] = rand(0...@database_configs.size)
46
+ end
47
+
48
+ def local_current
49
+ @@currents[current_cached_key] = (@@currents[current_cached_key] + 1) % @database_configs.size
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,51 @@
1
+ require_relative "./algo"
2
+
3
+ module LoadBalancer
4
+ class WeightRoundRobin < Algo
5
+ cattr_reader :mutexes, default: {}
6
+ cattr_reader :cum_weights, default: {}
7
+ cattr_reader :weight_sums, default: ::Hash.new(0)
8
+
9
+ def warm_up
10
+ (@@mutexes[weights_key] ||= Mutex.new).synchronize do
11
+ return if @@cum_weights.has_key?(weights_key) or @database_configs.empty?
12
+
13
+ @@cum_weights[weights_key] = [@database_configs[0][:weight]]
14
+ (1...@database_configs.size).each do |i|
15
+ @@weight_sums[weight_sum_key] += @database_configs[i][:weight]
16
+ @@cum_weights[weights_key][i] = @@cum_weights[weights_key][i-1] + @database_configs[i][:weight]
17
+ end
18
+ end
19
+ end
20
+
21
+ def next_db(**options)
22
+ db_index = pick_db_by_random_weight
23
+ return @database_configs[db_index], db_index if db_available?(db_index)
24
+
25
+ # fail over
26
+ next_dbs = (db_index+1...db_index+@database_configs.size).map { |i| i % @database_configs.size }
27
+ fail_over(next_dbs)
28
+ end
29
+
30
+ private
31
+
32
+ def weights_key
33
+ "#{@key}:weights"
34
+ end
35
+
36
+ def weight_sum_key
37
+ "#{@key}:weight_sum"
38
+ end
39
+
40
+ def random_weight
41
+ rand(@@weight_sums[weight_sum_key]-1)
42
+ end
43
+
44
+ def pick_db_by_random_weight
45
+ rw = random_weight
46
+ @@cum_weights[weights_key].find_index do |weight|
47
+ weight > rw
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,53 @@
1
+ require "active_support"
2
+ require 'digest'
3
+ require_relative "./redis_lua"
4
+ require_relative "./healthcheck"
5
+ require_relative "./distribute_lock"
6
+ require_relative "./load_balancer/errors"
7
+ require_relative "./load_balancer/round_robin"
8
+ require_relative "./load_balancer/least_connection"
9
+ require_relative "./load_balancer/weight_round_robin"
10
+ require_relative "./load_balancer/hash"
11
+ require_relative "./load_balancer/randomized"
12
+ require_relative "./load_balancer/least_response_time"
13
+
14
+ module LoadBalancer
15
+ extend ::ActiveSupport::Concern
16
+
17
+ mattr_accessor :db_down_time, default: 120
18
+ mattr_accessor :redis_down_time, default: 120
19
+ mattr_reader :lb, default: {}
20
+
21
+ def init(name, db_configs, algorithm: :round_robin, redis: nil)
22
+ lb_algo_clazz = "LoadBalancer::#{algorithm.to_s.classify}".constantize
23
+ LoadBalancer.lb[name] = {
24
+ clazz: lb_algo_clazz,
25
+ db_configs: db_configs,
26
+ redis: redis,
27
+ key: name
28
+ }
29
+
30
+ DistributeLock.new(redis).synchronize(name) do
31
+ lb = lb_algo_clazz.new(db_configs, redis: redis, key: name)
32
+ lb.warm_up
33
+ end
34
+ end
35
+ module_function :init
36
+
37
+ module ClassMethods
38
+ def load_balancing(name, db_configs, algorithm: :round_robin, redis: nil)
39
+ LoadBalancer.init(name, db_configs, algorithm: algorithm, redis: redis)
40
+ end
41
+
42
+ def connected_through_load_balancer(name, **options, &blk)
43
+ raise ArgumentError, "not found #{name} load balancer" unless LoadBalancer.lb.has_key?(name)
44
+
45
+ configs = LoadBalancer.lb[name]
46
+ lb = configs[:clazz].new(configs[:db_configs], redis: configs[:redis], key: configs[:key])
47
+ lb.connected_to_next_db(**options, &blk)
48
+ end
49
+ alias_method :connected_through, :connected_through_load_balancer
50
+ alias_method :connected_by, :connected_through_load_balancer
51
+ end
52
+ end
53
+
@@ -0,0 +1,90 @@
1
+ class MinHeap
2
+ def initialize(comparator = lambda { |x, y| x <=> y })
3
+ @comparator = comparator
4
+ @items = []
5
+ end
6
+
7
+ def push(x)
8
+ @items.push(x)
9
+ swim_up(@items.size-1)
10
+ end
11
+
12
+ def pop
13
+ @items[0], @items[@items.size-1] = @items[@items.size-1], @items[0]
14
+ @items.pop.tap { sink_down(0) }
15
+ end
16
+
17
+ def peak
18
+ @items[0]
19
+ end
20
+
21
+ def update(item_index)
22
+ return if item_index < 0 || item_index >= @items.size
23
+
24
+ swim_up(item_index)
25
+ sink_down(item_index)
26
+ end
27
+
28
+ def decrease(index, delta)
29
+ item_index = find_item_index(index)
30
+ @items[item_index][0] -= delta
31
+ update(item_index)
32
+ end
33
+
34
+ def increase(index, delta)
35
+ item_index = find_item_index(index)
36
+ @items[item_index][0] += delta
37
+ update(item_index)
38
+ end
39
+
40
+ def replace(index, val)
41
+ item_index = find_item_index(index)
42
+ @items[item_index][0] = val
43
+ update(item_index)
44
+ end
45
+
46
+ # def find_item(index)
47
+ # @items.find { |item| item.last == index }
48
+ # end
49
+
50
+ def find_item_index(index)
51
+ @items.find_index { |item| item.last == index }
52
+ end
53
+
54
+ def empty?
55
+ @items.empty?
56
+ end
57
+
58
+ def order
59
+ @items.sort.map(&:last)
60
+ end
61
+
62
+ private
63
+
64
+ def parent(i)
65
+ ((i-1)/2).floor
66
+ end
67
+
68
+ def left(i)
69
+ 2*i + 1
70
+ end
71
+
72
+ def swim_up(i)
73
+ pi = parent(i)
74
+ if pi >= 0 && @comparator.call(@items[i], @items[pi]) <= 0
75
+ @items[pi], @items[i] = @items[i], @items[pi]
76
+ swim_up(pi)
77
+ end
78
+ end
79
+
80
+ def sink_down(i)
81
+ return if (li = left(i)) >= @items.size
82
+
83
+ ri = li + 1
84
+ swap_i = (li == @items.size-1 || @comparator.call(@items[li], @items[ri]) <= 0) ? li : ri
85
+ if (@comparator.call(@items[swap_i], @items[i]) <= 0)
86
+ @items[swap_i], @items[i] = @items[i], @items[swap_i]
87
+ sink_down(swap_i)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,12 @@
1
+ module RedisLua
2
+ def eval_lua_script(script, sha1, *args, redis: @redis,**kwargs)
3
+ redis.evalsha sha1, *args, **kwargs
4
+ rescue ::Redis::CommandError => e
5
+ if e.to_s =~ /^NOSCRIPT/
6
+ redis.eval script, *args, **kwargs
7
+ else
8
+ raise
9
+ end
10
+ end
11
+ module_function :eval_lua_script
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiDbsLoadBalancer
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "multi_dbs_load_balancer/version"
4
+ require_relative "multi_dbs_load_balancer/load_balancer"
5
+
6
+ module MultiDbsLoadBalancer
7
+ class Error < StandardError; end
8
+
9
+ def setup
10
+ yield(LoadBalancer)
11
+ end
12
+ module_function :setup
13
+ end
14
+
15
+ ActiveSupport.on_load(:active_record) do
16
+ ActiveRecord::Base.send(:include, LoadBalancer)
17
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ require "rails/generators"
3
+
4
+ module MultiDbsLoadBalancer
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ source_root File.expand_path("../templates", __FILE__)
7
+
8
+ def create_initializer
9
+ copy_file "initializer.rb", "config/initializers/multi_dbs_load_balancer.rb"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ MultiDbsLoadBalancer.setup do |load_balancer|
2
+ ## The interval time that any have-down(disconnected, error, ...) db will be ignored,
3
+ ## after that, the load balancer will retry to pick this db if the algorithm point to this
4
+ # load_balancer.db_down_time = 120
5
+
6
+ ## The interval time that all algorithms will process in local server if the redis disconnected,
7
+ ## after that, they will retry to connect to redis again.
8
+ # load_balancer.redis_down_time = 120
9
+
10
+ # load_balancer.init :round_robin,
11
+ # [
12
+ # {role: :reading1},
13
+ # {role: :reading2},
14
+ # {role: :reading3},
15
+ # ],
16
+ # algorithm: :round_robin,
17
+ # redis: Redis.new(host: 'localhost')
18
+ #
19
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/multi_dbs_load_balancer/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "multi_dbs_load_balancer"
7
+ spec.version = MultiDbsLoadBalancer::VERSION
8
+ spec.authors = ["theforestvn88"]
9
+ spec.email = ["theforestvn88@gmail.com"]
10
+
11
+ spec.summary = "rails multiple databases load balancer"
12
+ spec.description = "rails multiple databases load balancer"
13
+ spec.homepage = "https://github.com/theforestvn88/rails_dbs_load_balancer"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.6.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/theforestvn88/rails_dbs_load_balancer"
19
+ spec.metadata["changelog_uri"] = "https://github.com/theforestvn88/rails_dbs_load_balancer"
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_runtime_dependency 'activerecord', '>= 6.0'
34
+ spec.add_runtime_dependency 'activesupport', '>= 6.0'
35
+
36
+ end
@@ -0,0 +1,4 @@
1
+ module MultiDbsLoadBalancer
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: multi_dbs_load_balancer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - theforestvn88
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-03-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ description: rails multiple databases load balancer
42
+ email:
43
+ - theforestvn88@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rspec"
49
+ - LICENSE.txt
50
+ - README.md
51
+ - Rakefile
52
+ - benchmark.rb
53
+ - lib/multi_dbs_load_balancer.rb
54
+ - lib/multi_dbs_load_balancer/distribute_lock.rb
55
+ - lib/multi_dbs_load_balancer/healthcheck.rb
56
+ - lib/multi_dbs_load_balancer/load_balancer.rb
57
+ - lib/multi_dbs_load_balancer/load_balancer/algo.rb
58
+ - lib/multi_dbs_load_balancer/load_balancer/errors.rb
59
+ - lib/multi_dbs_load_balancer/load_balancer/hash.rb
60
+ - lib/multi_dbs_load_balancer/load_balancer/least_connection.rb
61
+ - lib/multi_dbs_load_balancer/load_balancer/least_response_time.rb
62
+ - lib/multi_dbs_load_balancer/load_balancer/randomized.rb
63
+ - lib/multi_dbs_load_balancer/load_balancer/round_robin.rb
64
+ - lib/multi_dbs_load_balancer/load_balancer/weight_round_robin.rb
65
+ - lib/multi_dbs_load_balancer/min_heap.rb
66
+ - lib/multi_dbs_load_balancer/redis_lua.rb
67
+ - lib/multi_dbs_load_balancer/version.rb
68
+ - lib/rails/generators/multi_dbs_load_balancer/install_generator.rb
69
+ - lib/rails/generators/multi_dbs_load_balancer/templates/initializer.rb
70
+ - multi_dbs_load_balancer.gemspec
71
+ - sig/multi_dbs_load_balancer.rbs
72
+ homepage: https://github.com/theforestvn88/rails_dbs_load_balancer
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/theforestvn88/rails_dbs_load_balancer
77
+ source_code_uri: https://github.com/theforestvn88/rails_dbs_load_balancer
78
+ changelog_uri: https://github.com/theforestvn88/rails_dbs_load_balancer
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 2.6.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.5.4
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: rails multiple databases load balancer
98
+ test_files: []