Skip to content

Commit

Permalink
Deprecate 'master' in favor of 'primary'
Browse files Browse the repository at this point in the history
  • Loading branch information
mlarraz committed Jan 26, 2021
1 parent d62c515 commit d3c5775
Show file tree
Hide file tree
Showing 29 changed files with 320 additions and 276 deletions.
12 changes: 6 additions & 6 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2021-01-19 22:10:17 UTC using RuboCop version 1.8.1.
# on 2021-01-20 22:15:06 UTC using RuboCop version 1.8.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
Expand Down Expand Up @@ -71,7 +71,7 @@ Layout/FirstHashElementIndentation:
- 'lib/makara/config_parser.rb'
- 'spec/strategies/shard_aware_spec.rb'

# Offense count: 4
# Offense count: 10
# Cop supports --auto-correct.
# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle.
# SupportedHashRocketStyles: key, separator, table
Expand Down Expand Up @@ -243,7 +243,7 @@ Lint/UnusedBlockArgument:
Exclude:
- 'lib/makara/strategies/priority_failover.rb'

# Offense count: 28
# Offense count: 25
# Cop supports --auto-correct.
# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods.
Lint/UnusedMethodArgument:
Expand Down Expand Up @@ -283,7 +283,7 @@ Metrics/BlockNesting:
# Offense count: 4
# Configuration parameters: CountComments, CountAsOne.
Metrics/ClassLength:
Max: 240
Max: 253

# Offense count: 3
# Configuration parameters: IgnoredMethods.
Expand Down Expand Up @@ -514,7 +514,7 @@ Style/NumericPredicate:
- 'lib/makara/context.rb'
- 'lib/makara/proxy.rb'

# Offense count: 2
# Offense count: 3
# Configuration parameters: AllowedMethods.
# AllowedMethods: respond_to_missing?
Style/OptionalBooleanParameter:
Expand Down Expand Up @@ -662,7 +662,7 @@ Style/TrivialAccessors:
Style/WordArray:
EnforcedStyle: brackets

# Offense count: 25
# Offense count: 27
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
# URISchemes: http, https
Expand Down
74 changes: 37 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Code Climate](https://codeclimate.com/repos/526886a7f3ea00679b00cae6/badges/7905f7a000492a1078f7/gpa.png)](https://codeclimate.com/repos/526886a7f3ea00679b00cae6/feed)


Makara is generic master/replica proxy. It handles the heavy lifting of managing, choosing, blacklisting, and cycling through connections. It comes with an ActiveRecord database adapter implementation.
Makara is generic primary/replica proxy. It handles the heavy lifting of managing, choosing, blacklisting, and cycling through connections. It comes with an ActiveRecord database adapter implementation.

## Installation

Expand Down Expand Up @@ -37,18 +37,18 @@ Next, you need to decide which methods are proxied and which methods should be s
send_to_all :connect, :reconnect, :disconnect, :clear_cache
```

Assuming you don't need to split requests between a master and a replica, you're done. If you do need to, implement the `needs_master?` method:
Assuming you don't need to split requests between a primary and a replica, you're done. If you do need to, implement the `needs_primary?` method:

```ruby
# within MyAwesomeSqlProxy
def needs_master?(method_name, args)
def needs_primary?(method_name, args)
return false if args.empty?
sql = args.first
sql !~ /^select/i
end
```

This implementation will send any request not like "SELECT..." to a master connection. There are more methods you can override and more control over blacklisting - check out the [makara database adapter](lib/active_record/connection_adapters/makara_abstract_adapter.rb) for examples of advanced usage.
This implementation will send any request not like "SELECT..." to a primary connection. There are more methods you can override and more control over blacklisting - check out the [makara database adapter](lib/active_record/connection_adapters/makara_abstract_adapter.rb) for examples of advanced usage.

### Config Parsing

Expand All @@ -58,13 +58,13 @@ Makara comes with a config parser which will handle providing subconfigs to the

Makara handles stickiness by keeping track of which proxies are stuck at any given moment. The context is basically a mapping of proxy ids to the timestamp until which they are stuck.

To handle persistence of context across requests in a Rack app, makara provides a middleware. It lays a cookie named `_mkra_stck` which contains the current context. If the next request is executed before the cookie expires, that given context will be used. If something occurs which naturally requires master on the second request, the context is updated and stored again.
To handle persistence of context across requests in a Rack app, makara provides a middleware. It lays a cookie named `_mkra_stck` which contains the current context. If the next request is executed before the cookie expires, that given context will be used. If something occurs which naturally requires the primary on the second request, the context is updated and stored again.

#### Stickiness Impact

When `sticky:true`, once a query as been sent to master, all queries for the rest of the request will also be sent to master. In addition, the cookie described above will be set client side with an expiration defined by time at end of original request + `master_ttl`. As long as the cookie is valid, all requests will send queries to master.
When `sticky:true`, once a query as been sent to the primary, all queries for the rest of the request will also be sent to the primary. In addition, the cookie described above will be set client side with an expiration defined by time at end of original request + `primary_ttl`. As long as the cookie is valid, all requests will send queries to primary.

When `sticky:false`, only queries that need to go to master will go there. Subsequent read queries in the same request will go to replicas.
When `sticky:false`, only queries that need to go to the primary will go there. Subsequent read queries in the same request will go to replicas.

#### Releasing stuck connections (clearing context)

Expand All @@ -82,28 +82,28 @@ Makara::Context.release('redis')
...
```

A context is local to the curent thread of execution. This will allow you to stick to master safely in a single thread
A context is local to the curent thread of execution. This will allow you to stick to the primary safely in a single thread
in systems such as sidekiq, for instance.


#### Forcing Master
#### Forcing Primary

If you need to force master in your app then you can simply invoke stick_to_master! on your connection:
If you need to force the primary in your app then you can simply invoke stick_to_primary! on your connection:

```ruby
persist = true # or false, it's true by default
proxy.stick_to_master!(persist)
proxy.stick_to_primary!(persist)
```

It'll keep the proxy stuck to master for the current request, and if `persist = true` (default), it'll be also stored in the context for subsequent requests, keeping the proxy stuck up to the duration of `master_ttl` configured for the proxy.
It'll keep the proxy stuck to the primary for the current request, and if `persist = true` (default), it'll be also stored in the context for subsequent requests, keeping the proxy stuck up to the duration of `primary_ttl` configured for the proxy.

#### Skipping the Stickiness

If you're using the `sticky: true` configuration and you find yourself in a situation where you need to write information through the proxy but you don't want the context to be stuck to master, you should use a `without_sticking` block:
If you're using the `sticky: true` configuration and you find yourself in a situation where you need to write information through the proxy but you don't want the context to be stuck to the primary, you should use a `without_sticking` block:

```ruby
proxy.without_sticking do
# do my stuff that would normally cause the proxy to stick to master
# do my stuff that would normally cause the proxy to stick to the primary
end
```

Expand All @@ -117,23 +117,23 @@ Makara::Logging::Logger.logger = ::Logger.new(STDOUT)

## ActiveRecord Database Adapter

So you've found yourself with an ActiveRecord-based project which is starting to get some traffic and you realize 95% of you DB load is from reads. Well you've come to the right spot. Makara is a great solution to break up that load not only between master and replica but potentially multiple masters and/or multiple replicas.
So you've found yourself with an ActiveRecord-based project which is starting to get some traffic and you realize 95% of you DB load is from reads. Well you've come to the right spot. Makara is a great solution to break up that load not only between primary and replica but potentially multiple primaries and/or multiple replicas.

By creating a makara database adapter which simply acts as a proxy we avoid any major complexity surrounding specific database implementations. The makara adapter doesn't care if the underlying connection is mysql, postgresql, etc it simply cares about the sql string being executed.

### What goes where?

In general: Any `SELECT` statements will execute against your replica(s), anything else will go to master.
In general: Any `SELECT` statements will execute against your replica(s), anything else will go to the primary.

There are some edge cases:
* `SET` operations will be sent to all connections
* Execution of specific methods such as `connect!`, `disconnect!`, and `clear_cache!` are invoked on all underlying connections
* Calls inside a transaction will always be sent to the master (otherwise changes from within the transaction could not be read back on most transaction isolation levels)
* Locking reads (e.g. `SELECT ... FOR UPDATE`) will always be sent to the master
* Calls inside a transaction will always be sent to the primary (otherwise changes from within the transaction could not be read back on most transaction isolation levels)
* Locking reads (e.g. `SELECT ... FOR UPDATE`) will always be sent to the primary

### Errors / blacklisting

Whenever a node fails an operation due to a connection issue, it is blacklisted for the amount of time specified in your database.yml. After that amount of time has passed, the connection will begin receiving queries again. If all replica nodes are blacklisted, the master connection will begin receiving read queries as if it were a replica. Once all nodes are blacklisted the error is raised to the application and all nodes are whitelisted.
Whenever a node fails an operation due to a connection issue, it is blacklisted for the amount of time specified in your database.yml. After that amount of time has passed, the connection will begin receiving queries again. If all replica nodes are blacklisted, the primary connection will begin receiving read queries as if it were a replica. Once all nodes are blacklisted the error is raised to the application and all nodes are whitelisted.

### Database.yml

Expand All @@ -152,15 +152,15 @@ production:
id: mysql
# the following are default values
blacklist_duration: 5
master_ttl: 5
master_strategy: round_robin
primary_ttl: 5
primary_strategy: round_robin
sticky: true

# list your connections with the override values (they're merged into the top-level config)
# be sure to provide the role if master, role is assumed to be a replica if not provided
# be sure to provide the role if primary, role is assumed to be a replica if not provided
connections:
- role: master
host: master.sql.host
- role: primary
host: primary.sql.host
- role: replica
host: replica1.sql.host
- role: replica
Expand All @@ -174,19 +174,19 @@ Following the adapter choice is all the standard configurations (host, port, ret
The makara subconfig sets up the proxy with a few of its own options, then provides the connection list. The makara options are:
* id - an identifier for the proxy, used for sticky behaviour and context. The default is to use a MD5 hash of the configuration contents, so if you are setting `sticky` to true, it's a good idea to also set an `id`. Otherwise any stuck connections will be cleared if the configuration changes (as the default MD5 hash id would change as well)
* blacklist_duration - the number of seconds a node is blacklisted when a connection failure occurs
* disable_blacklist - do not blacklist node at any error, useful in case of one master
* disable_blacklist - do not blacklist node at any error, useful in case of one primary
* sticky - if a node should be stuck to once it's used during a specific context
* master_ttl - how long the master context is persisted. generally, this needs to be longer than any replication lag
* master_strategy - use a different strategy for picking the "current" master node: `failover` will try to keep the same one until it is blacklisted. The default is `round_robin` which will cycle through available ones.
* primary_ttl - how long the primary context is persisted. generally, this needs to be longer than any replication lag
* primary_strategy - use a different strategy for picking the "current" primary node: `failover` will try to keep the same one until it is blacklisted. The default is `round_robin` which will cycle through available ones.
* replica_strategy - use a different strategy for picking the "current" replica node: `failover` will try to keep the same one until it is blacklisted. The default is `round_robin` which will cycle through available ones.
* connection_error_matchers - array of custom error matchers you want to be handled gracefully by Makara (as in, errors matching these regexes will result in blacklisting the connection as opposed to raising directly).

Connection definitions contain any extra node-specific configurations. If the node should behave as a master you must provide `role: master`. Any previous configurations can be overridden within a specific node's config. Nodes can also contain weights if you'd like to balance usage based on hardware specifications. Optionally, you can provide a name attribute which will be used in sql logging.
Connection definitions contain any extra node-specific configurations. If the node should behave as a primary you must provide `role: primary`. Any previous configurations can be overridden within a specific node's config. Nodes can also contain weights if you'd like to balance usage based on hardware specifications. Optionally, you can provide a name attribute which will be used in sql logging.

```yml
connections:
- role: master
host: mymaster.sql.host
- role: primary
host: myprimary.sql.host
blacklist_duration: 0
# implicit role: replica
Expand All @@ -206,7 +206,7 @@ Connections may specify a `url` parameter in place of host, username, password,

```yml
connections:
- role: master
- role: primary
blacklist_duration: 0
url: 'mysql2://db_username:db_password@localhost:3306/db_name'
```
Expand All @@ -215,9 +215,9 @@ We recommend, if using environmental variables, to interpolate them via ERb.

```yml
connections:
- role: master
- role: primary
blacklist_duration: 0
url: <%= ENV['DATABASE_URL_MASTER'] %>
url: <%= ENV['DATABASE_URL_PRIMARY'] %>
- role: replica
url: <%= ENV['DATABASE_URL_REPLICA'] %>
```
Expand All @@ -244,7 +244,7 @@ For more information on url parsing, as used in
- ActiveRecord::ConnectionHandling::MergeAndResolveDefaultUrlConfig.new(url_config).resolve
- 4.2
[ActiveRecord::ConnectionAdapters::ConnectionSpecification::ConnectionUrlResolver.new(url).to_hash](https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/connection_handling.rb#L60-L81)
- master
- primary
[ActiveRecord::ConnectionAdapters::ConnectionSpecification::ConnectionUrlResolver.new(url).to_hash](https://github.com/rails/rails/blob/97b980b4e61aea3cee429bdee4b2eae2329905cd/activerecord/lib/active_record/connection_handling.rb#L60-L81)


Expand All @@ -269,15 +269,15 @@ You can provide strings or regexes. In the case of strings, if they start with

## Common Problems / Solutions

On occasion your app may deal with a situation where makara is not present during a write but a read should use master. In the generic proxy details above you are encouraged to use `stick_to_master!` to accomplish this. Here's an example:
On occasion your app may deal with a situation where makara is not present during a write but a read should use primary. In the generic proxy details above you are encouraged to use `stick_to_primary!` to accomplish this. Here's an example:

```ruby
# some third party creates a resource in your db, replication may not have completed yet
# ...
# then your app is told to read the resource.
def handle_request_after_third_party_record_creation
CreatedResourceClass.connection.stick_to_master!
CreatedResourceClass.find(params[:id]) # will go to master
CreatedResourceClass.connection.stick_to_primary!
CreatedResourceClass.find(params[:id]) # will go to the primary
end
```

Expand Down
34 changes: 21 additions & 13 deletions lib/active_record/connection_adapters/makara_abstract_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,24 +95,31 @@ def custom_error_message?(connection, message)
control_method :close, :steal!, :expire, :lease, :in_use?, :owner, :schema_cache, :pool=, :pool,
:schema_cache=, :lock, :seconds_idle, :==

SQL_MASTER_MATCHERS = [/\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i, /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i].map(&:freeze).freeze
SQL_PRIMARY_MATCHERS = [/\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i, /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i].map(&:freeze).freeze
SQL_REPLICA_MATCHERS = [/\A\s*(select|with.+\)\s*select)\s/i].map(&:freeze).freeze
SQL_ALL_MATCHERS = [/\A\s*set\s/i].map(&:freeze).freeze
SQL_SKIP_STICKINESS_MATCHERS = [/\A\s*show\s([\w]+\s)?(field|table|database|schema|view|index)(es|s)?/i, /\A\s*(set|describe|explain|pragma)\s/i].map(&:freeze).freeze

SQL_MASTER_MATCHERS = SQL_PRIMARY_MATCHERS
deprecate_constant :SQL_MASTER_MATCHERS
SQL_SLAVE_MATCHERS = SQL_REPLICA_MATCHERS
deprecate_constant :SQL_SLAVE_MATCHERS

def sql_primary_matchers
SQL_PRIMARY_MATCHERS
end

def sql_master_matchers
SQL_MASTER_MATCHERS
warn "#{self.class}#sql_master_matchers is deprecated. Use #sql_primary_matchers"
sql_primary_matchers
end

def sql_replica_matchers
SQL_REPLICA_MATCHERS
end

def sql_slave_matchers
warn "sql_slave_matchers is deprecated. Use sql_replica_matchers"
warn "#{self.class}#sql_slave_matchers is deprecated. Use #sql_replica_matchers"
sql_replica_matchers
end

Expand All @@ -134,21 +141,17 @@ def initialize(config)

def appropriate_connection(method_name, args, &block)
if needed_by_all?(method_name, args)

handling_an_all_execution(method_name) do
hijacked do
# replica pool must run first.
@replica_pool.send_to_all(nil, &block) # just yields to each con
@master_pool.send_to_all(nil, &block) # just yields to each con
@primary_pool.send_to_all(nil, &block) # just yields to each con
end
end

else

super(method_name, args) do |con|
yield con
end

end
end

Expand All @@ -166,12 +169,17 @@ def needed_by_all?(method_name, args)
false
end

def needs_master?(method_name, args)
sql = coerce_query_to_sql_string(args.first)
return true if sql_master_matchers.any?{|m| sql =~ m }
return false if sql_replica_matchers.any?{|m| sql =~ m }
def needs_primary?(method_name, args)
if respond_to?(:needs_master?)
warn "#{self.class}#needs_master? is deprecated. Switch to #needs_primary?"
needs_master?(method_name, args)
else
sql = coerce_query_to_sql_string(args.first)
return true if sql_primary_matchers.any?{|m| sql =~ m }
return false if sql_replica_matchers.any?{|m| sql =~ m }

true
true
end
end

def coerce_query_to_sql_string(sql_or_arel)
Expand Down
Loading

0 comments on commit d3c5775

Please sign in to comment.