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.
@@ -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)