octoball 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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"
@@ -0,0 +1,7 @@
1
+ /.bundle
2
+ /database.log
3
+ .byebug_history
4
+ /vendor
5
+ /pkg
6
+ *.lock
7
+ *.gem
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
@@ -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
@@ -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
@@ -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)