octoball 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 +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)
|