multi_dbs_load_balancer 0.0.1

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: 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: []