knockoff 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: e0025a70e9bc0d93706b972c83a1bf27ed62a836
4
- data.tar.gz: 7eada472ea6751980a6109d047f7600cce5590a3
3
+ metadata.gz: f6249cf98485220a497bcfe3ae9fc7056606451f
4
+ data.tar.gz: ee8347e81037c6a8641849473fb08ddc304eff2c
5
5
  SHA512:
6
- metadata.gz: 7743e8ea20c39af558a3425d4b2e565dad7b9f38967833eeaece006874aba0662d364b8383b87c699448750feca896eced1e4bd54de7b9831c55f82cc2fcbcad
7
- data.tar.gz: 6d1a25ea1b2578529e59b22ac11668a1d13355b2857000a83a169f91eb28cc2e3bcdff94356e0c5544df9de81503fb100acffc27503bae3168b38d41a9f04e22
6
+ metadata.gz: 1b2ca3c16d58470005cea91b6f356f540335de2b461f34367a43b8a545dae477cdee607fab03a318915fc638d93001e3774f35616e5211053379dd057d8a201d
7
+ data.tar.gz: 92e4702b63d1378d8cacf0f8a02f423880b665c6d6bf19930c30ff0d0d174fdb2e94be4c5b39b3d717ccdedaf5265cd18a54cae05c444b810c0cd00023ff2a70
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Knockoff (WIP)
2
2
 
3
3
  [![Build Status](https://travis-ci.org/sgringwe/knockoff.svg?branch=master)](https://travis-ci.org/sgringwe/knockoff)
4
+ [![Gem Version](https://badge.fury.io/rb/knockoff.svg)](https://badge.fury.io/rb/knockoff)
4
5
 
5
6
  A gem for easily using read replicas. Heavily based off of https://github.com/kenn/slavery and https://github.com/kickstarter/replica_pools gem.
6
7
 
@@ -30,9 +31,145 @@ Or install it yourself as:
30
31
 
31
32
  ## Usage
32
33
 
33
- TODO
34
+ ### Initializer
34
35
 
35
- ### Usage Notes
36
+ Add an initializer at config/knockoff.rb with the below contents
37
+
38
+ ```
39
+ Knockoff.enabled = true # NOTE: Consider adding ENV based disabling
40
+ ```
41
+
42
+ ### Configuration
43
+
44
+ Configuration is done using ENV properties. This makes it easy to add and remove replicas at runtime (or to fully disable if needed). First, set up ENV variables pointing to your replica databases. Consider using the [dotenv](https://github.com/bkeepers/dotenv) gem for manging ENV variables.
45
+
46
+ ```
47
+ # .env
48
+
49
+ REPLICA_1=postgres://username:password@localhost:5432/database_name
50
+ ```
51
+
52
+ The second ENV variable to set is `KNOCKOFF_REPLICA_ENVS` which is a comma-separated list of ENVS holding database URLs to use as replicas. In this case, the ENV would be set as follows.
53
+
54
+ ```
55
+ # .env
56
+
57
+ KNOCKOFF_REPLICA_ENVS=REPLICA_1
58
+ ```
59
+
60
+ Note that it can be multiple replicas, and `knockoff` will use both evenly:
61
+
62
+ ```
63
+ KNOCKOFF_REPLICA_ENVS=REPLICA_1,REPLICA_2
64
+ ```
65
+
66
+ Lastly, knockoff will read the `'knockoff_replicas'` database.yml config for specifying additional params:
67
+
68
+ ```
69
+ # database.yml
70
+
71
+ knockoff_replicas:
72
+ <<: *common
73
+ prepared_statements: false
74
+ ```
75
+
76
+ ### Basics
77
+
78
+ To use one of the replica databases, use
79
+
80
+ ```
81
+ Knockoff.on_replica { User.count }
82
+ ```
83
+
84
+ To force primary, use
85
+
86
+ ```
87
+ Knockoff.on_primary { User.create(name: 'Bob') }
88
+ ```
89
+
90
+ ### Using in Controllers
91
+
92
+ A common use case is to use replicas for GET requests and otherwise use primary. A simplified use case might look something like this:
93
+
94
+ ```
95
+ # application_controller.rb
96
+
97
+ around_action :choose_database
98
+
99
+ def choose_database(&block)
100
+ if should_use_primary_database?
101
+ Knockoff.on_primary(&block)
102
+ else
103
+ Knockoff.on_replica(&block)
104
+ end
105
+ end
106
+
107
+ def should_use_primary_database?
108
+ request.method_symbol != :get
109
+ end
110
+
111
+ ```
112
+
113
+ #### Replication Lag
114
+
115
+ Replicas will often be slightly behind the primary database. To compensate, consider "sticking" a user who has recently made changes to the primary for a small duration of time to the primary database. This will avoid cases where a user creates a record on primary, is redirected to view that record, and receives a 404 error since the record is not yet in the replica. A simple implementation for this could look like:
116
+
117
+ ```
118
+ # application_record.rb
119
+
120
+ after_commit :track_commit_occurred_in_request
121
+
122
+ # If any commit happens in a request, we record that and have the logged_in_user
123
+ # read from primary for a short period of time.
124
+ def track_commit_occurred_in_request
125
+ RequestLocals.store['commit_occurred_in_current_request'] = true
126
+ end
127
+
128
+ # application_controller.rb
129
+
130
+ after_action :force_leader_if_commit
131
+
132
+ def force_leader_if_commit
133
+ if RequestLocals.store['commit_occurred_in_current_request'].to_b
134
+ session[:use_leader_until] = Time.current + FORCE_PRIMARY_DURATION
135
+ end
136
+ end
137
+
138
+ ```
139
+
140
+ Then, in your `should_use_primary_database?` method, consult `RequestLocals['commit_occurred_in_current_request']` for the decision.
141
+
142
+ ### Run-time Configuration
143
+
144
+ Knockoff can be configured during runtime. This is done through the `establish_new_connections!` method which takes in a hash of new configurations to apply to each replica before re-connecting.
145
+
146
+ ```
147
+ Knockoff.establish_new_connections!({ 'pool' => db_pool })
148
+ ```
149
+
150
+ For example, to specify a puma connection pool at bootup your code might look something like
151
+
152
+ ```
153
+ # puma.rb
154
+
155
+ db_pool = Integer(ENV['PUMA_WORKER_DB_POOL'] || threads_count)
156
+ # Configure the database connection to have the new pool size and re-establish connection
157
+ database_config = ActiveRecord::Base.configurations[Rails.env] || Rails.application.config.database_configuration[Rails.env]
158
+ database_config['pool'] = db_pool
159
+ ActiveRecord::Base.establish_connection(database_config)
160
+ Knockoff.establish_new_connections!({ 'pool' => db_pool })
161
+
162
+ ```
163
+
164
+ #### Forking
165
+
166
+ For forking servers, you may disconnect all replicas before forking with `Knockoff.disconnect_all!`.
167
+
168
+ ### Other Cases
169
+
170
+ There are likely other cases specific to each application where it makes sense to force primary database and avoid replication lag. Good candidates are time-based pages (a live calendar, for example), forms, and payments.
171
+
172
+ ## Usage Notes
36
173
 
37
174
  * Do not use prepared statements with this gem
38
175
 
@@ -44,7 +181,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
44
181
 
45
182
  ## Contributing
46
183
 
47
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/knockoff.
184
+ Bug reports and pull requests are welcome on GitHub at https://github.com/sgringwe/knockoff.
48
185
 
49
186
 
50
187
  ## License
@@ -19,6 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.require_paths = ["lib"]
20
20
 
21
21
  spec.add_runtime_dependency 'activerecord', '>= 4.0.0'
22
+ spec.add_runtime_dependency 'activesupport', '>= 4.0.0'
22
23
  spec.add_runtime_dependency 'request_store_rails', '>= 1.0.0'
23
24
 
24
25
  spec.add_development_dependency "bundler", "~> 1.10"
@@ -11,14 +11,6 @@ require 'knockoff/active_record/relation'
11
11
  module Knockoff
12
12
  class << self
13
13
  attr_accessor :enabled
14
- attr_writer :spec_key
15
-
16
- def spec_key
17
- case @spec_key
18
- when String then @spec_key
19
- when NilClass then @spec_key = "#{ActiveRecord::ConnectionHandling::RAILS_ENV.call}_replica"
20
- end
21
- end
22
14
 
23
15
  def on_replica(&block)
24
16
  Base.new(:replica).run(&block)
@@ -29,7 +21,20 @@ module Knockoff
29
21
  end
30
22
 
31
23
  def replica_pool
32
- @replica_pool ||= ReplicaConnectionPool.new(config.replica_uris)
24
+ @replica_pool ||= ReplicaConnectionPool.new(config.replica_database_keys)
25
+ end
26
+
27
+ # Iterates through the replica pool and calls disconnect on each one's connection.
28
+ def disconnect_all!
29
+ replica_pool.disconnect_all_replicas!
30
+ end
31
+
32
+ # Updates the config (both internal representation and the ActiveRecord::Base.configuration)
33
+ # with the new config, and then reconnects each replica connection in the replica
34
+ # pool.
35
+ def establish_new_connections!(new_config)
36
+ config.update_replica_configs(new_config)
37
+ replica_pool.reconnect_all_replicas!
33
38
  end
34
39
 
35
40
  def config
@@ -2,15 +2,22 @@ module Knockoff
2
2
  class Config
3
3
  # The current environment. Normally set to Rails.env, but
4
4
  # will default to 'development' outside of Rails apps.
5
- attr_accessor :environment
5
+ attr_reader :environment
6
6
 
7
- # An array of URIs to use for the replica pool.
8
- # TODO: Add support for inheriting from database.yml
9
- attr_accessor :replica_uris
7
+ # An array of configs to use for the replica pool.
8
+ attr_reader :replica_configs
9
+
10
+ # A hash of replica configs to their config hash.
11
+ attr_reader :replicas_configurations
10
12
 
11
13
  def initialize
12
14
  @environment = 'development'
13
- set_replica_uris
15
+ @replicas_configurations = {}
16
+ set_replica_configs
17
+ end
18
+
19
+ def replica_database_keys
20
+ @replicas_configurations.keys
14
21
  end
15
22
 
16
23
  def replica_env_keys
@@ -21,18 +28,66 @@ module Knockoff
21
28
  end
22
29
  end
23
30
 
31
+ def update_replica_configs(new_configs)
32
+ ActiveRecord::Base.configurations['knockoff_replicas'].merge(new_configs) if ActiveRecord::Base.configurations['knockoff_replicas'].present?
33
+ @replicas_configurations.each do |key, _config|
34
+ update_replica_config(key, new_configs)
35
+ end
36
+ end
37
+
24
38
  private
25
39
 
26
- def set_replica_uris
27
- @replica_uris ||= parse_knockoff_replica_envs_to_uris
40
+ def update_replica_config(key, new_configs)
41
+ @replicas_configurations[key].merge!(new_configs)
42
+ ActiveRecord::Base.configurations[key].merge!(new_configs)
28
43
  end
29
44
 
30
- def parse_knockoff_replica_envs_to_uris
45
+ def set_replica_configs
46
+ @replica_configs ||= parse_knockoff_replica_envs_to_configs
47
+ end
48
+
49
+ def parse_knockoff_replica_envs_to_configs
31
50
  # As a basic prevention of crashes, attempt to parse each DB uri
32
51
  # and don't add the uri to the final list if it can't be parsed
33
- replica_env_keys.map do |env_key|
52
+ replica_env_keys.map.with_index(0) do |env_key, index|
34
53
  begin
35
- URI.parse(ENV[env_key])
54
+ uri = URI.parse(ENV[env_key])
55
+
56
+ # Configure parameters such as prepared_statements, pool, reaping_frequency for all replicas.
57
+ replica_config = ActiveRecord::Base.configurations['knockoff_replicas'] || {}
58
+
59
+ adapter =
60
+ if uri.scheme == "postgres"
61
+ 'postgresql'
62
+ else
63
+ uri.scheme
64
+ end
65
+
66
+ # Base config from the ENV uri. Sqlite is a special case
67
+ # and all others follow 'normal' config
68
+ uri_config =
69
+ if uri.scheme == 'sqlite3'
70
+ {
71
+ 'adapter' => adapter,
72
+ 'database' => uri.to_s.split(':')[1]
73
+ }
74
+ else
75
+ {
76
+ 'adapter' => adapter,
77
+ 'database' => (uri.path || "").split("/")[1],
78
+ 'username' => uri.user,
79
+ 'password' => uri.password,
80
+ 'host' => uri.host,
81
+ 'port' => uri.port
82
+ }
83
+ end
84
+
85
+ # Store the hash in configuration and use it when we establish the connection later.
86
+ key = "knockoff_replica_#{index}"
87
+ full_config = replica_config.merge(uri_config)
88
+
89
+ ActiveRecord::Base.configurations[key] = full_config
90
+ @replicas_configurations[key] = full_config
36
91
  rescue URI::InvalidURIError
37
92
  Rails.logger.info "LOG NOTIFIER: Invalid URL specified in follower_env_keys. Not including URI, which may result in no followers used." # URI is purposely not printed to logs
38
93
  # Return a 'nil' which will be removed from
@@ -2,11 +2,25 @@ module Knockoff
2
2
  class ReplicaConnectionPool
3
3
  attr_reader :pool
4
4
 
5
- def initialize(uris)
5
+ def initialize(config_keys)
6
6
  @pool = Concurrent::Hash.new
7
7
 
8
- uris.each_with_index do |uri, index|
9
- @pool["replica_#{index}"] = connection_class(index, uri)
8
+ config_keys.each do |config_key|
9
+ @pool[config_key] = connection_class(config_key)
10
+ end
11
+ end
12
+
13
+ def disconnect_all_replicas!
14
+ @pool.each do |_name, klass|
15
+ klass.connection.disconnect!
16
+ end
17
+ end
18
+
19
+ # Assumes that the config has been updated to something new, and
20
+ # simply reconnects with the config.
21
+ def reconnect_all_replicas!
22
+ @pool.each do |database_key, klass|
23
+ klass.establish_connection database_key.to_sym
10
24
  end
11
25
  end
12
26
 
@@ -16,15 +30,16 @@ module Knockoff
16
30
 
17
31
  # Based off of code from replica_pools gem
18
32
  # generates a unique ActiveRecord::Base subclass for a single replica
19
- def connection_class(replica_index, uri)
20
- class_name = "Replica#{replica_index}"
33
+ def connection_class(config_key)
34
+ # Config key is of schema 'knockoff_replica_n'
35
+ class_name = "KnockoffReplica#{config_key.split('_').last}"
21
36
 
22
37
  # TODO: Hardcoding the uri string feels meh. Either set the database config
23
38
  # or reference ENV instead
24
39
  Knockoff.module_eval %Q{
25
40
  class #{class_name} < ActiveRecord::Base
26
41
  self.abstract_class = true
27
- establish_connection '#{uri}'
42
+ establish_connection :#{config_key}
28
43
  end
29
44
  }, __FILE__, __LINE__
30
45
 
@@ -1,3 +1,3 @@
1
1
  module Knockoff
2
- VERSION = '0.1.0'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: knockoff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Ringwelski
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-11-27 00:00:00.000000000 Z
11
+ date: 2016-12-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: 4.0.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: 4.0.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 4.0.0
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: request_store_rails
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -145,3 +159,4 @@ signing_key:
145
159
  specification_version: 4
146
160
  summary: A gem for easily using read replicas
147
161
  test_files: []
162
+ has_rdoc: