octoball 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +46 -0
- data/.gitignore +7 -0
- data/Gemfile +3 -0
- data/README.md +107 -0
- data/Rakefile +54 -0
- data/lib/octoball.rb +30 -0
- data/lib/octoball/association.rb +61 -0
- data/lib/octoball/association_shard_check.rb +44 -0
- data/lib/octoball/connection_adapters.rb +18 -0
- data/lib/octoball/connection_handling.rb +18 -0
- data/lib/octoball/current_shard_tracker.rb +30 -0
- data/lib/octoball/log_subscriber.rb +21 -0
- data/lib/octoball/persistence.rb +29 -0
- data/lib/octoball/relation_proxy.rb +99 -0
- data/lib/octoball/version.rb +5 -0
- data/octoball.gemspec +28 -0
- data/spec/migration/1_test_tables.rb +84 -0
- data/spec/migration/2_alone_shard_tables.rb +19 -0
- data/spec/models/application_record.rb +13 -0
- data/spec/octoball/association_shard_tracking_spec.rb +1024 -0
- data/spec/octoball/collection_proxy_spec.rb +17 -0
- data/spec/octoball/log_subscriber_spec.rb +19 -0
- data/spec/octoball/model_spec.rb +688 -0
- data/spec/octoball/relation_proxy_spec.rb +130 -0
- data/spec/octoball/scope_proxy_spec.rb +97 -0
- data/spec/rails_helper.rb +0 -0
- data/spec/spec_helper.rb +31 -0
- data/spec/support/database_connection.rb +22 -0
- data/spec/support/database_models.rb +115 -0
- data/spec/support/query_count.rb +17 -0
- data/spec/support/shared_contexts.rb +18 -0
- data/spec/support/test_helper.rb +14 -0
- metadata +174 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: '0669e5023c27ce0b1ec2e5116331e8b9f6e5fa405e9f9ac87ea198ecc10023e4'
|
4
|
+
data.tar.gz: 0b222767e80de3ad53e689d0ffda6e7fe87b4ce551e3bcd2eacc6beb79b98abe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9282d81935c2c736cd9d9abec4b9a5a42b595aabb64326bd77a4f74027fb12e04855bf8f7197f9abc83f24488eac3e9a64e4689ab6a275da7764999a252f812c
|
7
|
+
data.tar.gz: 0c00b0f6ee82aaa84278b0343b8a264f999e34eabe7d7fe96c99d6845bd56496745c5e39479a34033b8dd01fd87ab036859faeaf510e2d2a54b34ef7288f9df1
|
@@ -0,0 +1,46 @@
|
|
1
|
+
version: 2.1
|
2
|
+
|
3
|
+
executors:
|
4
|
+
ruby:
|
5
|
+
parameters:
|
6
|
+
version:
|
7
|
+
type: string
|
8
|
+
docker:
|
9
|
+
- image: circleci/ruby:<< parameters.version >>
|
10
|
+
- image: circleci/mysql:5
|
11
|
+
environment:
|
12
|
+
- RAILS_ENV: test
|
13
|
+
- MYSQL_HOST: 127.0.0.1
|
14
|
+
|
15
|
+
jobs:
|
16
|
+
rspec:
|
17
|
+
parameters:
|
18
|
+
version:
|
19
|
+
type: string
|
20
|
+
executor:
|
21
|
+
name: ruby
|
22
|
+
version: << parameters.version >>
|
23
|
+
steps:
|
24
|
+
- checkout
|
25
|
+
- run:
|
26
|
+
name: bundle install
|
27
|
+
command: |
|
28
|
+
gem update bundler
|
29
|
+
bundle config --local path vendor/bundle
|
30
|
+
bundle install --jobs=4 --retry=3
|
31
|
+
- run:
|
32
|
+
name: Setup databases
|
33
|
+
command: bundle exec rake db:prepare
|
34
|
+
- run:
|
35
|
+
name: Run tests
|
36
|
+
command: bundle exec rspec
|
37
|
+
|
38
|
+
workflows:
|
39
|
+
version: 2
|
40
|
+
rspecs:
|
41
|
+
jobs:
|
42
|
+
- rspec:
|
43
|
+
matrix:
|
44
|
+
parameters:
|
45
|
+
version:
|
46
|
+
- "2.7"
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
# Octoball - Octopus-like sharding helper library for ActiveRecord 6.1+
|
2
|
+
|
3
|
+
<img src="https://user-images.githubusercontent.com/26372128/98494380-3711be00-2280-11eb-8805-6f9e47aeee21.jpg" align="left" width=120>
|
4
|
+
|
5
|
+
Octoball provides [Octopus](https://github.com/thiagopradi/octopus)-like database sharding helpers for ActiveRecord 6.1+.
|
6
|
+
This will make it easier to upgrade Rails to 6.1+ for applications using [Octopus gem](https://github.com/thiagopradi/octopus) for database sharding with Rails 4.x/5.x.
|
7
|
+
|
8
|
+
Currently, its implementation is focusing on horizontal database sharding. However, by customizing shard key mapping, it can be applied to replication use case too.
|
9
|
+
<br clear="both">
|
10
|
+
|
11
|
+
## Scope of this gem
|
12
|
+
|
13
|
+
### What is included in Octoball
|
14
|
+
- Octopus-like shard swithcing by `using` class method, e.g.:
|
15
|
+
```ruby
|
16
|
+
Octoball.using(:shard1) { User.find_by_name("Alice") }
|
17
|
+
User.using(:shard1).first
|
18
|
+
```
|
19
|
+
- Each model instance knows which shard it came from so shard will be switched automatically:
|
20
|
+
```ruby
|
21
|
+
user1 = User.using(:shard1).find_by_name("Bob")
|
22
|
+
user2 = User.using(:shard2).find_by_name("Charlie")
|
23
|
+
user1.age += 1
|
24
|
+
user2.age += 1
|
25
|
+
user1.save! # Save the user1 in the correct shard `:shard1`
|
26
|
+
user2.save! # Save the user2 in the correct shard `:shard2`
|
27
|
+
```
|
28
|
+
- Relations such as `has_many` are also resolved from the model instance's shard:
|
29
|
+
```ruby
|
30
|
+
user = User.using(:shard1).find_by_name("Alice")
|
31
|
+
user.blogs.where(title: "blog") # user's blogs are fetched from `:shard1`
|
32
|
+
```
|
33
|
+
|
34
|
+
### What is NOT included in Octoball
|
35
|
+
- Connection handling and configuration -- managed by the native `ActiveRecord::Base.connects_to` methods introduced in ActiveRecord 6.1.
|
36
|
+
- You need to migrate from Octopus' `config/shards.yml` to [Rails native multiple DB configuration using `config/database.yml`](https://edgeguides.rubyonrails.org/active_record_multiple_databases.html). Please refer the [Setup](#Setup) section for more details.
|
37
|
+
- Migration -- done by ActiveRecord 6.1+ natively.
|
38
|
+
- Instead of `using` method in Octopus, you can specify the `migrations_paths` parameter in the `config/database.yml` file.
|
39
|
+
- Replication handling -- done by ActiveRecord's `role`
|
40
|
+
- round-robin connection scheduler is currently omitted.
|
41
|
+
|
42
|
+
## Setup
|
43
|
+
|
44
|
+
```
|
45
|
+
gem "octoball"
|
46
|
+
```
|
47
|
+
|
48
|
+
Until first release:
|
49
|
+
```
|
50
|
+
# Bundle edge Rails
|
51
|
+
gem 'rails', github: 'rails/rails'
|
52
|
+
gem 'octoball', git: 'git@github.com:aktsk/octoball'
|
53
|
+
```
|
54
|
+
|
55
|
+
Define the database connections in `config/database.yml`, e.g.:
|
56
|
+
```
|
57
|
+
default: &default
|
58
|
+
adapter: mysql2
|
59
|
+
pool: 5
|
60
|
+
username: root
|
61
|
+
host: localhost
|
62
|
+
timeout: 5000
|
63
|
+
connnect_timeout: 5000
|
64
|
+
|
65
|
+
development:
|
66
|
+
master:
|
67
|
+
<<: *default
|
68
|
+
database: db_primary
|
69
|
+
shard1_connection:
|
70
|
+
<<: *default
|
71
|
+
database: db_shard1
|
72
|
+
```
|
73
|
+
And define shards and corresponding connections in abstract ActiveRecord model class, e.g.:
|
74
|
+
```ruby
|
75
|
+
class ApplicationRecord < ActiveRecord::Base
|
76
|
+
self.abstract_class = true
|
77
|
+
|
78
|
+
connects_to shards: {
|
79
|
+
master: { writing: :master },
|
80
|
+
shard1: { writing: :shard1_connection },
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
class User < ApplicationRecord
|
85
|
+
...
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
Optionally, to use the `:master` shard as a default connection like Octopus, add the following script to `config/initializers/default_shard.rb`:
|
90
|
+
```
|
91
|
+
ActiveRecord::Base.default_shard = :master
|
92
|
+
```
|
93
|
+
|
94
|
+
|
95
|
+
## Development of Octoball
|
96
|
+
Octoball has rspec tests delived from subsets of Octopus' rspec.
|
97
|
+
|
98
|
+
To run the rspec tests, follow these steps:
|
99
|
+
```
|
100
|
+
RAILS_ENV=test bundle exec rake db:prepare
|
101
|
+
RAILS_ENV=test bundle exec rake spec
|
102
|
+
```
|
103
|
+
|
104
|
+
## License
|
105
|
+
Octoball is released under the MIT license.
|
106
|
+
|
107
|
+
Original Octopus' copyright: Copyright (c) Thiago Pradi
|
data/Rakefile
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/gem_tasks'
|
4
|
+
require 'rspec/core/rake_task'
|
5
|
+
require 'rubocop/rake_task'
|
6
|
+
|
7
|
+
RSpec::Core::RakeTask.new
|
8
|
+
RuboCop::RakeTask.new
|
9
|
+
|
10
|
+
namespace :db do
|
11
|
+
mysql_spec = {
|
12
|
+
adapter: 'mysql2',
|
13
|
+
host: (ENV['MYSQL_HOST'] || 'localhost'),
|
14
|
+
username: (ENV['MYSQL_USER'] || 'root'),
|
15
|
+
encoding: 'utf8mb4',
|
16
|
+
}
|
17
|
+
|
18
|
+
desc 'Build the databases for tests'
|
19
|
+
task :build_databases do
|
20
|
+
require 'active_record'
|
21
|
+
|
22
|
+
# Connect to MYSQL
|
23
|
+
ActiveRecord::Base.establish_connection(mysql_spec)
|
24
|
+
(1..5).map do |i|
|
25
|
+
# drop the old database (if it exists)
|
26
|
+
ActiveRecord::Base.connection.drop_database("octoball_shard_#{i}")
|
27
|
+
# create new database
|
28
|
+
ActiveRecord::Base.connection.create_database("octoball_shard_#{i}", charset: 'utf8mb4')
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
desc 'Create tables on tests databases'
|
33
|
+
task :create_tables do
|
34
|
+
ActiveRecord::Base.configurations = {
|
35
|
+
"test" => {
|
36
|
+
shard1: mysql_spec.merge(database: 'octoball_shard_1'),
|
37
|
+
shard2: mysql_spec.merge(database: 'octoball_shard_2'),
|
38
|
+
shard3: mysql_spec.merge(database: 'octoball_shard_3'),
|
39
|
+
shard4: mysql_spec.merge(database: 'octoball_shard_4'),
|
40
|
+
shard5: mysql_spec.merge(database: 'octoball_shard_5'),
|
41
|
+
}
|
42
|
+
}
|
43
|
+
require './spec/models/application_record'
|
44
|
+
ActiveRecord::Base.configurations.configs_for(env_name: "test").each do |config|
|
45
|
+
ActiveRecord::Base.establish_connection(config)
|
46
|
+
schema_migration = ActiveRecord::Base.connection.schema_migration
|
47
|
+
ActiveRecord::MigrationContext.new("spec/migration", schema_migration)
|
48
|
+
.migrate(config.database == 'octoball_shard_5' ? 2 : 1)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
desc 'Prepare the test databases'
|
53
|
+
task prepare: [:build_databases, :create_tables]
|
54
|
+
end
|
data/lib/octoball.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
require 'octoball/version'
|
5
|
+
require 'octoball/relation_proxy'
|
6
|
+
require 'octoball/connection_adapters'
|
7
|
+
require 'octoball/connection_handling'
|
8
|
+
require 'octoball/current_shard_tracker'
|
9
|
+
require 'octoball/association'
|
10
|
+
require 'octoball/association_shard_check'
|
11
|
+
require 'octoball/persistence'
|
12
|
+
require 'octoball/log_subscriber'
|
13
|
+
|
14
|
+
class Octoball
|
15
|
+
def self.using(shard, &block)
|
16
|
+
ActiveRecord::Base.connected_to(role: current_role, shard: shard&.to_sym, &block)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.current_role
|
20
|
+
ActiveRecord::Base.current_role || ActiveRecord::Base.writing_role
|
21
|
+
end
|
22
|
+
|
23
|
+
module UsingShard
|
24
|
+
def using(shard)
|
25
|
+
Octoball::RelationProxy.new(all, shard&.to_sym)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
::ActiveRecord::Base.singleton_class.prepend(UsingShard)
|
30
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Octoball
|
4
|
+
module RelationCurrentShard
|
5
|
+
attr_accessor :current_shard
|
6
|
+
end
|
7
|
+
|
8
|
+
module ShardedCollectionAssociation
|
9
|
+
[:reader, :writer, :ids_reader, :ids_writer, :create, :create!,
|
10
|
+
:build, :include?, :load_target, :reload, :size, :select].each do |method|
|
11
|
+
class_eval <<-"END", __FILE__, __LINE__ + 1
|
12
|
+
def #{method}(*args, &block)
|
13
|
+
shard = owner.current_shard
|
14
|
+
return super if !shard || shard == ActiveRecord::Base.current_shard
|
15
|
+
ActiveRecord::Base.connected_to(shard: shard, role: Octoball.current_role) do
|
16
|
+
ret = super
|
17
|
+
return ret unless ret.is_a?(::ActiveRecord::Relation) || ret.is_a?(::ActiveRecord::QueryMethods::WhereChain)
|
18
|
+
RelationProxy.new(ret, shard)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
ruby2_keywords(:#{method}) if respond_to?(:ruby2_keywords, true)
|
22
|
+
END
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module ShardedCollectionProxy
|
27
|
+
[:any?, :build, :count, :create, :create!, :concat, :delete, :delete_all,
|
28
|
+
:destroy, :destroy_all, :empty?, :find, :first, :include?, :last, :length,
|
29
|
+
:many?, :pluck, :replace, :select, :size, :sum, :to_a, :uniq].each do |method|
|
30
|
+
class_eval <<-"END", __FILE__, __LINE__ + 1
|
31
|
+
def #{method}(*args, &block)
|
32
|
+
return super if !@association.owner.current_shard || @association.owner.current_shard == ActiveRecord::Base.current_shard
|
33
|
+
ActiveRecord::Base.connected_to(shard: @association.owner.current_shard, role: Octoball.current_role) do
|
34
|
+
super
|
35
|
+
end
|
36
|
+
end
|
37
|
+
ruby2_keywords(:#{method}) if respond_to?(:ruby2_keywords, true)
|
38
|
+
END
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
module ShardedSingularAssociation
|
43
|
+
[:reader, :writer, :create, :create!, :build].each do |method|
|
44
|
+
class_eval <<-"END", __FILE__, __LINE__ + 1
|
45
|
+
def #{method}(*args, &block)
|
46
|
+
return super if !owner.current_shard || owner.current_shard == ActiveRecord::Base.current_shard
|
47
|
+
ActiveRecord::Base.connected_to(shard: owner.current_shard, role: Octoball.current_role) do
|
48
|
+
super
|
49
|
+
end
|
50
|
+
end
|
51
|
+
ruby2_keywords(:#{method}) if respond_to?(:ruby2_keywords, true)
|
52
|
+
END
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
::ActiveRecord::Relation.prepend(RelationCurrentShard)
|
57
|
+
::ActiveRecord::QueryMethods::WhereChain.prepend(RelationCurrentShard)
|
58
|
+
::ActiveRecord::Associations::CollectionAssociation.prepend(ShardedCollectionAssociation)
|
59
|
+
::ActiveRecord::Associations::CollectionProxy.prepend(ShardedCollectionProxy)
|
60
|
+
::ActiveRecord::Associations::SingularAssociation.prepend(ShardedSingularAssociation)
|
61
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Octoball
|
4
|
+
class MismatchedShards < StandardError
|
5
|
+
attr_reader :record, :current_shard
|
6
|
+
|
7
|
+
def initialize(record, current_shard)
|
8
|
+
@record = record
|
9
|
+
@current_shard = current_shard
|
10
|
+
end
|
11
|
+
|
12
|
+
def message
|
13
|
+
"Association shard mismatch: record shard is \"#{record.current_shard}\" but current shard is \"#{current_shard}\""
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module AssociationShardCheck
|
18
|
+
def association_shard_check(record)
|
19
|
+
fail MismatchedShards.new(record, current_shard) if record.current_shard != current_shard
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
module AssociationShardChecker
|
24
|
+
def has_many(name, scope = nil, **options, &extension)
|
25
|
+
assign_octoball_check_opts(options)
|
26
|
+
super
|
27
|
+
end
|
28
|
+
|
29
|
+
def has_and_belongs_to_many(association_id, scope = nil, **options, &extension)
|
30
|
+
assign_octoball_check_opts(options)
|
31
|
+
super
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def assign_octoball_check_opts(options)
|
37
|
+
options[:before_add] = [:association_shard_check, options[:before_add]].compact.flatten
|
38
|
+
options[:before_remove] = [:association_shard_check, options[:before_remove]].compact.flatten
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
::ActiveRecord::Base.prepend(AssociationShardCheck)
|
43
|
+
::ActiveRecord::Base.singleton_class.prepend(AssociationShardChecker)
|
44
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Octoball
|
4
|
+
module ConnectionHandlerSetCurrentShard
|
5
|
+
def retrieve_connection(spec_name, role: ActiveRecord::Base.current_role, shard: ActiveRecord::Base.current_shard)
|
6
|
+
conn = super
|
7
|
+
conn.current_shard = shard
|
8
|
+
conn
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
module ConnectionHasCurrentShard
|
13
|
+
attr_accessor :current_shard
|
14
|
+
end
|
15
|
+
|
16
|
+
::ActiveRecord::ConnectionAdapters::ConnectionHandler.prepend(ConnectionHandlerSetCurrentShard)
|
17
|
+
::ActiveRecord::ConnectionAdapters::AbstractAdapter.prepend(ConnectionHasCurrentShard)
|
18
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Octoball
|
4
|
+
module ConnectionHandlingAvoidAutoLoadProxy
|
5
|
+
private
|
6
|
+
|
7
|
+
def swap_connection_handler(handler, &blk)
|
8
|
+
old_handler, ActiveRecord::Base.connection_handler = ActiveRecord::Base.connection_handler, handler
|
9
|
+
return_value = yield
|
10
|
+
return_value.load if !return_value.respond_to?(:ar_relation) && return_value.is_a?(ActiveRecord::Relation)
|
11
|
+
return_value
|
12
|
+
ensure
|
13
|
+
ActiveRecord::Base.connection_handler = old_handler
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
::ActiveRecord::Base.extend(ConnectionHandlingAvoidAutoLoadProxy)
|
18
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Octoball
|
4
|
+
module CurrentShardTracker
|
5
|
+
attr_reader :current_shard
|
6
|
+
|
7
|
+
def becomes(klass)
|
8
|
+
became = super
|
9
|
+
became.instance_variable_set(:@current_shard, current_shard)
|
10
|
+
became
|
11
|
+
end
|
12
|
+
|
13
|
+
def ==(other)
|
14
|
+
super && current_shard == other.current_shard
|
15
|
+
end
|
16
|
+
|
17
|
+
module ClassMethods
|
18
|
+
private
|
19
|
+
|
20
|
+
def instantiate_instance_of(klass, attributes, column_types = {}, &block)
|
21
|
+
result = super
|
22
|
+
result.instance_variable_set(:@current_shard, connection.current_shard)
|
23
|
+
result
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
::ActiveRecord::Base.prepend(CurrentShardTracker)
|
29
|
+
::ActiveRecord::Base.singleton_class.prepend(CurrentShardTracker::ClassMethods)
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# Implementation courtesy of db-charmer.
|
2
|
+
class Octoball
|
3
|
+
module LogSubscriber
|
4
|
+
attr_accessor :current_shard
|
5
|
+
|
6
|
+
def sql(event)
|
7
|
+
shard = event.payload[:connection]&.current_shard
|
8
|
+
self.current_shard = shard == ActiveRecord::Base.default_shard ? nil : shard
|
9
|
+
super
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def debug(progname = nil, &block)
|
15
|
+
conn = current_shard ? color("[Shard: #{current_shard}]", ActiveSupport::LogSubscriber::GREEN, true) : ''
|
16
|
+
super(conn + progname.to_s, &block)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
ActiveRecord::LogSubscriber.prepend(Octoball::LogSubscriber)
|