readyset 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +15 -0
- data/.rubocop_airbnb.yml +2 -0
- data/.ruby-version +1 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +383 -0
- data/Rakefile +12 -0
- data/config.ru +9 -0
- data/docker-compose.yml +54 -0
- data/lib/active_record/connection_adapters/readyset_adapter.rb +52 -0
- data/lib/active_record/readyset_connection_handling.rb +18 -0
- data/lib/readyset/caches.rb +20 -0
- data/lib/readyset/configuration.rb +30 -0
- data/lib/readyset/controller_extension.rb +34 -0
- data/lib/readyset/error.rb +3 -0
- data/lib/readyset/explain.rb +60 -0
- data/lib/readyset/health/healthchecker.rb +127 -0
- data/lib/readyset/health/healthchecks.rb +41 -0
- data/lib/readyset/model_extension.rb +40 -0
- data/lib/readyset/query/cached_query.rb +104 -0
- data/lib/readyset/query/proxied_query.rb +116 -0
- data/lib/readyset/query/queryable.rb +23 -0
- data/lib/readyset/query.rb +23 -0
- data/lib/readyset/railtie.rb +68 -0
- data/lib/readyset/relation_extension.rb +29 -0
- data/lib/readyset/utils/window_counter.rb +58 -0
- data/lib/readyset/version.rb +5 -0
- data/lib/readyset.rb +168 -0
- data/lib/tasks/readyset.rake +162 -0
- data/lib/templates/caches.rb.tt +11 -0
- data/readyset.gemspec +52 -0
- data/sig/readyset.rbs +4 -0
- metadata +321 -0
@@ -0,0 +1,34 @@
|
|
1
|
+
module Readyset
|
2
|
+
# Module providing controller extensions for routing ActiveRecord queries to a replica database.
|
3
|
+
module ControllerExtension
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
prepended do
|
7
|
+
# Sets up an `around_action` for a specified set of controller actions.
|
8
|
+
# This method is used to route the specified actions through Readyset,
|
9
|
+
# allowing ActiveRecord queries within those actions to be handled by a replica database.
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# route_to_readyset only: [:index, :show]
|
13
|
+
# route_to_readyset :index
|
14
|
+
# route_to_readyset except: :index
|
15
|
+
# route_to_readyset :show, only: [:index, :show], if: -> { some_condition }
|
16
|
+
#
|
17
|
+
# @param args [Array<Symbol, Hash>] A list of actions and/or options dictating when the
|
18
|
+
# around_action should apply.
|
19
|
+
# The options can include Rails' standard `:only`, `:except`, and conditionals like `:if`.
|
20
|
+
# @yield [_controller, action_block] An optional block that will execute around the actions.
|
21
|
+
# Yields the block from the controller action.
|
22
|
+
# @yieldparam _controller [ActionController::Base] Param is unused.
|
23
|
+
# @yieldparam action_block [Proc] The block passed along with the action.
|
24
|
+
#
|
25
|
+
def self.route_to_readyset(*args, &block)
|
26
|
+
around_action(*args, *block) do |_controller, action_block|
|
27
|
+
Readyset.route do
|
28
|
+
action_block.call
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module Readyset
|
2
|
+
# Represents the result of an `EXPLAIN CREATE CACHE` invocation on ReadySet.
|
3
|
+
class Explain
|
4
|
+
attr_reader :id, :text, :supported
|
5
|
+
|
6
|
+
# Gets information about the given query from ReadySet, including whether it's supported to be
|
7
|
+
# cached, its current status, the rewritten query text, and the query ID.
|
8
|
+
#
|
9
|
+
# The information about the given query is retrieved by invoking `EXPLAIN CREATE CACHE FROM` on
|
10
|
+
# ReadySet.
|
11
|
+
#
|
12
|
+
# @param [String] a query about which information should be retrieved
|
13
|
+
# @return [Explain]
|
14
|
+
def self.call(query)
|
15
|
+
raw_results = Readyset.raw_query("EXPLAIN CREATE CACHE FROM #{query}")
|
16
|
+
from_readyset_results(**raw_results.first.to_h.symbolize_keys)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Creates a new `Explain` with the given attributes.
|
20
|
+
#
|
21
|
+
# @param [String] id the ID of the query
|
22
|
+
# @param [String] text the query text
|
23
|
+
# @param [Symbol] supported the supported status of the query
|
24
|
+
# @return [Explain]
|
25
|
+
def initialize(id:, text:, supported:) # :nodoc:
|
26
|
+
@id = id
|
27
|
+
@text = text
|
28
|
+
@supported = supported
|
29
|
+
end
|
30
|
+
|
31
|
+
# Compares `self` with another `Explain` by comparing them attribute-wise.
|
32
|
+
#
|
33
|
+
# @param [Explain] other the `Explain` to which `self` should be compared
|
34
|
+
# @return [Boolean]
|
35
|
+
def ==(other)
|
36
|
+
id == other.id &&
|
37
|
+
text == other.text &&
|
38
|
+
supported == other.supported
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns true if the explain information returned by ReadySet indicates that the query is
|
42
|
+
# unsupported.
|
43
|
+
#
|
44
|
+
# @return [Boolean]
|
45
|
+
def unsupported?
|
46
|
+
supported == :unsupported
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def self.from_readyset_results(**attributes)
|
52
|
+
new(
|
53
|
+
id: attributes[:'query id'],
|
54
|
+
text: attributes[:query],
|
55
|
+
supported: attributes[:'readyset supported'].to_sym,
|
56
|
+
)
|
57
|
+
end
|
58
|
+
private_class_method :from_readyset_results
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'uri'
|
3
|
+
|
4
|
+
require 'readyset/health/healthchecks'
|
5
|
+
|
6
|
+
module Readyset
|
7
|
+
module Health
|
8
|
+
# Processes the given exceptions to determine whether ReadySet is currently unhealthy. If
|
9
|
+
# ReadySet is indeed unhealthy, a background task is spawned that periodically checks
|
10
|
+
# ReadySet's health directly until a healthy state has been restored. While ReadySet is in an
|
11
|
+
# unhealthy state, `Healthchecker#healthy?` will return false.
|
12
|
+
class Healthchecker
|
13
|
+
UNHEALTHY_ERRORS = [::PG::UnableToSend, ::PG::ConnectionBad].freeze
|
14
|
+
|
15
|
+
def initialize(config, shard:)
|
16
|
+
@healthy = Concurrent::AtomicBoolean.new(true)
|
17
|
+
@healthcheck_interval = config.healthcheck_interval!
|
18
|
+
@healthchecks = Health::Healthchecks.new(shard: shard)
|
19
|
+
@lock = Mutex.new
|
20
|
+
@shard = shard
|
21
|
+
@window_counter = Readyset::Utils::WindowCounter.new(
|
22
|
+
window_size: config.error_window_size!,
|
23
|
+
time_period: config.error_window_period!,
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Returns true only if the connection to ReadySet is healthy. ReadySet's health is gauged by
|
28
|
+
# keeping track of the number of connection errors that have occurred over a given time
|
29
|
+
# period. If the number of errors in that time period exceeds the preconfigured threshold,
|
30
|
+
# ReadySet is considered to be unhealthy.
|
31
|
+
#
|
32
|
+
# @return [Boolean] whether ReadySet is healthy
|
33
|
+
def healthy?
|
34
|
+
healthy.true?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Checks if the given exception is a connection error that occurred on a ReadySet connection,
|
38
|
+
# and if so, logs the error internally. If ReadySet is unhealthy, a background task is
|
39
|
+
# spawned that periodically tries to connect to ReadySet and check its status. When this task
|
40
|
+
# determines that ReadySet is healthy again, the task is shut down and the state of the
|
41
|
+
# healthchecker is switched back to "healthy".
|
42
|
+
#
|
43
|
+
# @param [Exception] the exception to be processed
|
44
|
+
def process_exception(exception)
|
45
|
+
is_readyset_connection_error = is_readyset_connection_error?(exception)
|
46
|
+
window_counter.log if is_readyset_connection_error
|
47
|
+
|
48
|
+
# We lock here to ensure that only one thread starts the healthcheck task
|
49
|
+
lock.lock
|
50
|
+
if healthy.true? && window_counter.threshold_crossed?
|
51
|
+
healthy.make_false
|
52
|
+
lock.unlock
|
53
|
+
|
54
|
+
logger.warn('ReadySet unhealthy: Routing queries to their original destination until ' \
|
55
|
+
'ReadySet becomes healthy again')
|
56
|
+
|
57
|
+
disconnect_readyset_pool!
|
58
|
+
task.execute
|
59
|
+
end
|
60
|
+
ensure
|
61
|
+
lock.unlock if lock.locked?
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
attr_reader :healthcheck_interval, :healthchecks, :healthy, :lock, :shard, :window_counter
|
67
|
+
|
68
|
+
def build_task
|
69
|
+
@task ||= Concurrent::TimerTask.new(execution_interval: healthcheck_interval) do |t|
|
70
|
+
if healthchecks.healthy?
|
71
|
+
# We disconnect the ReadySet connection pool here to ensure that any pre-existing
|
72
|
+
# connections to ReadySet are re-established. This fixes an issue where connections
|
73
|
+
# return "PQsocket() can't get socket descriptor" errors even after ReadySet comes
|
74
|
+
# back up. See this stackoverflow post for more details:
|
75
|
+
# https://stackoverflow.com/q/36582380
|
76
|
+
disconnect_readyset_pool!
|
77
|
+
|
78
|
+
# We need to disconnect the pool before making `healthy` true to ensure that, once we
|
79
|
+
# start routing queries back to ReadySet, they are using fresh connections
|
80
|
+
lock.synchronize { healthy.make_true }
|
81
|
+
|
82
|
+
logger.info('ReadySet healthy again')
|
83
|
+
|
84
|
+
# We clear out the window counter here to ensure that errors from ReadySet's previous
|
85
|
+
# unhealthy state don't bias the healthchecker towards determining that ReadySet is
|
86
|
+
# unhealthy after only a small number of new errors
|
87
|
+
window_counter.clear
|
88
|
+
|
89
|
+
t.shutdown
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
observer = Object.new.instance_eval do
|
94
|
+
def update(_time, _result, e)
|
95
|
+
logger.debug("ReadySet still unhealthy: #{e}") if e
|
96
|
+
end
|
97
|
+
end
|
98
|
+
task.add_observer(observer)
|
99
|
+
|
100
|
+
task
|
101
|
+
end
|
102
|
+
|
103
|
+
def disconnect_readyset_pool!
|
104
|
+
ActiveRecord::Base.connected_to(shard: shard) do
|
105
|
+
ActiveRecord::Base.connection_pool.disconnect!
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def is_readyset_connection_error?(exception)
|
110
|
+
if exception.cause
|
111
|
+
is_readyset_connection_error?(exception.cause)
|
112
|
+
else
|
113
|
+
UNHEALTHY_ERRORS.any? { |e| exception.is_a?(e) } &&
|
114
|
+
exception.is_a?(Readyset::Error)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def logger
|
119
|
+
@logger ||= Rails.logger
|
120
|
+
end
|
121
|
+
|
122
|
+
def task
|
123
|
+
@task ||= build_task
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Readyset
|
2
|
+
module Health
|
3
|
+
# Represents healthchecks that are run against ReadySet to determine whether ReadySet is in a
|
4
|
+
# state where it can serve queries.
|
5
|
+
class Healthchecks
|
6
|
+
def initialize(shard:)
|
7
|
+
@shard = shard
|
8
|
+
end
|
9
|
+
|
10
|
+
# Checks if ReadySet is healthy by invoking `SHOW READYSET STATUS` and checking if
|
11
|
+
# ReadySet is connected to the upstream database.
|
12
|
+
#
|
13
|
+
# @return [Boolean] whether ReadySet is healthy
|
14
|
+
def healthy?
|
15
|
+
connection.execute('SHOW READYSET STATUS').any? do |row|
|
16
|
+
row['name'] == 'Database Connection' && row['value'] == 'Connected'
|
17
|
+
end
|
18
|
+
rescue
|
19
|
+
false
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :shard
|
25
|
+
|
26
|
+
def connection
|
27
|
+
@connection ||= ActiveRecord::Base.connected_to(shard: shard) do
|
28
|
+
ActiveRecord::Base.retrieve_connection
|
29
|
+
end
|
30
|
+
|
31
|
+
# We reconnect with each healthcheck to ensure that connection state is not cached across
|
32
|
+
# uses
|
33
|
+
@connection.reconnect!
|
34
|
+
|
35
|
+
@connection
|
36
|
+
rescue
|
37
|
+
false
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Readyset
|
2
|
+
module ModelExtension
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
prepended do
|
6
|
+
require 'active_support'
|
7
|
+
|
8
|
+
# Defines a new class method on a model that wraps the ActiveRecord query in `body` in a call
|
9
|
+
# to `Readyset.route`.
|
10
|
+
#
|
11
|
+
# NOTE: `body` should consist of nothing other than an ActiveRecord query! If you need to run
|
12
|
+
# actions before or after the query execution, you should wrap the invocation of the named
|
13
|
+
# query in another method.
|
14
|
+
#
|
15
|
+
# @param [Symbol] name the name of the method that will be defined
|
16
|
+
# @param [Proc] body a lambda that wraps an ActiveRecord query
|
17
|
+
def self.readyset_query(name, body)
|
18
|
+
unless body.respond_to?(:call)
|
19
|
+
raise ArgumentError, 'The query body needs to be callable.'
|
20
|
+
end
|
21
|
+
|
22
|
+
if dangerous_class_method?(name)
|
23
|
+
raise ArgumentError, "You tried to define a ReadySet query named \"#{name}\" " \
|
24
|
+
"on the model \"#{self.name}\", but Active Record already defined " \
|
25
|
+
'a class method with the same name.'
|
26
|
+
end
|
27
|
+
|
28
|
+
if method_defined_within?(name, ActiveRecord::Relation)
|
29
|
+
raise ArgumentError, "You tried to define a ReadySet query named \"#{name}\" " \
|
30
|
+
"on the model \"#{self.name}\", but ActiveRecord::Relation already defined " \
|
31
|
+
'an instance method with the same name.'
|
32
|
+
end
|
33
|
+
|
34
|
+
singleton_class.define_method(name) do |*args|
|
35
|
+
Readyset.route { body.call(*args) }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'active_model'
|
2
|
+
|
3
|
+
require 'readyset/query/queryable'
|
4
|
+
|
5
|
+
module Readyset
|
6
|
+
module Query
|
7
|
+
# Represents a query that is cached by ReadySet.
|
8
|
+
class CachedQuery
|
9
|
+
include ActiveModel::AttributeMethods
|
10
|
+
extend Queryable
|
11
|
+
|
12
|
+
attr_reader :id, :text, :name, :count, :always
|
13
|
+
|
14
|
+
# Returns all of the queries currently cached on ReadySet by invoking the `SHOW CACHES` SQL
|
15
|
+
# extension on ReadySet.
|
16
|
+
#
|
17
|
+
# @return [Array<CachedQuery>]
|
18
|
+
def self.all
|
19
|
+
super('SHOW CACHES')
|
20
|
+
end
|
21
|
+
|
22
|
+
# Drops all the caches that exist on ReadySet.
|
23
|
+
#
|
24
|
+
# @return [void]
|
25
|
+
def self.drop_all!
|
26
|
+
Readyset.raw_query('DROP ALL CACHES')
|
27
|
+
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns the cached query with the given query ID by directly querying ReadySet. If a cached
|
32
|
+
# query with the given ID doesn't exist, this method raises a
|
33
|
+
# `Readyset::Query::NotFoundError`.
|
34
|
+
#
|
35
|
+
# @param [String] id the ID of the query to be searched for
|
36
|
+
# @return [CachedQuery]
|
37
|
+
# @raise [Readyset::Query::NotFoundError] raised if a cached query with the given ID cannot be
|
38
|
+
# found
|
39
|
+
def self.find(id)
|
40
|
+
super('SHOW CACHES WHERE query_id = ?', id)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Constructs a new `CachedQuery` from the given attributes.
|
44
|
+
#
|
45
|
+
# @param [Hash] attributes the attributes from which the `CachedQuery` should be
|
46
|
+
# constructed
|
47
|
+
# @return [CachedQuery]
|
48
|
+
def initialize(id: nil, text:, name: nil, always: nil, count: nil)
|
49
|
+
@id = id
|
50
|
+
@text = text
|
51
|
+
@name = name
|
52
|
+
@always = always
|
53
|
+
@count = count
|
54
|
+
end
|
55
|
+
|
56
|
+
# Checks two queries for equality by comparing all of their attributes.
|
57
|
+
#
|
58
|
+
# @param [CachedQuery] the query against which `self` should be compared
|
59
|
+
# @return [Boolean]
|
60
|
+
def ==(other)
|
61
|
+
id == other.id &&
|
62
|
+
text == other.text &&
|
63
|
+
name == other.name &&
|
64
|
+
always == other.always
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns false if the cached query supports falling back to the upstream database and true
|
68
|
+
# otherwise.
|
69
|
+
#
|
70
|
+
# @return [Boolean]
|
71
|
+
def always?
|
72
|
+
always
|
73
|
+
end
|
74
|
+
|
75
|
+
# Drops the cache associated with this query.
|
76
|
+
#
|
77
|
+
# @return [void]
|
78
|
+
def drop!
|
79
|
+
Readyset.drop_cache!(name)
|
80
|
+
ProxiedQuery.find(id)
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
# Constructs a new `CachedQuery` from the given attributes. The attributes accepted
|
86
|
+
# by this method of this hash should correspond to the columns in the results returned by the
|
87
|
+
# `SHOW CACHES` ReadySet SQL extension.
|
88
|
+
#
|
89
|
+
# @param [Hash] attributes the attributes from which the `CachedQuery` should be
|
90
|
+
# constructed
|
91
|
+
# @return [CachedQuery]
|
92
|
+
def self.from_readyset_result(**attributes)
|
93
|
+
new(
|
94
|
+
id: attributes[:'query id'],
|
95
|
+
text: attributes[:'query text'],
|
96
|
+
name: attributes[:'cache name'],
|
97
|
+
always: attributes[:'fallback behavior'] != 'fallback allowed',
|
98
|
+
count: attributes[:count].to_i,
|
99
|
+
)
|
100
|
+
end
|
101
|
+
private_class_method :from_readyset_result
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
require 'active_model'
|
2
|
+
|
3
|
+
require 'readyset/query'
|
4
|
+
require 'readyset/query/queryable'
|
5
|
+
|
6
|
+
module Readyset
|
7
|
+
module Query
|
8
|
+
# Represents an uncached query that has been proxied by ReadySet.
|
9
|
+
class ProxiedQuery
|
10
|
+
include ActiveModel::AttributeMethods
|
11
|
+
extend Queryable
|
12
|
+
|
13
|
+
# An error raised when a `ProxiedQuery` is expected to be supported but isn't.
|
14
|
+
class UnsupportedError < Query::BaseError
|
15
|
+
def to_s
|
16
|
+
"Query #{id} is unsupported"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :id, :text, :supported, :count
|
21
|
+
|
22
|
+
# Returns all of the queries proxied by ReadySet that are not currently cached. This list is
|
23
|
+
# retrieved by invoking the `SHOW PROXIED QUERIES` SQL extension on ReadySet.
|
24
|
+
#
|
25
|
+
# @return [Array<ProxiedQuery>]
|
26
|
+
def self.all
|
27
|
+
super('SHOW PROXIED QUERIES')
|
28
|
+
end
|
29
|
+
|
30
|
+
# Creates a cache for every proxied query that is not already cached.
|
31
|
+
#
|
32
|
+
# @param [Boolean] always whether the cache should always be used. if this is true, queries
|
33
|
+
# to these caches will never fall back to the database
|
34
|
+
# @return [Array<CachedQuery>] an array of the newly-created caches
|
35
|
+
def self.cache_all_supported!(always: false)
|
36
|
+
all.
|
37
|
+
select { |query| query.supported == :yes }.
|
38
|
+
map { |query| query.cache!(always: always) }
|
39
|
+
end
|
40
|
+
|
41
|
+
# Clears the list of proxied queries on ReadySet.
|
42
|
+
def self.drop_all!
|
43
|
+
Readyset.raw_query('DROP ALL PROXIED QUERIES')
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns the proxied query with the given query ID. The query is searched for by directly
|
47
|
+
# querying ReadySet. If a proxied query with the given ID doesn't exist, this method raises a
|
48
|
+
# `Readyset::Query::NotFoundError`.
|
49
|
+
#
|
50
|
+
# @param [String] id the ID of the query to be searched for
|
51
|
+
# @return [ProxiedQuery]
|
52
|
+
# @raise [Readyset::Query::NotFoundError] raised if a proxied query with the given
|
53
|
+
# ID cannot be found
|
54
|
+
def self.find(id)
|
55
|
+
super('SHOW PROXIED QUERIES WHERE query_id = ?', id)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Constructs a new `ProxiedQuery` from the given attributes.
|
59
|
+
#
|
60
|
+
# @param [Hash] attributes the attributes from which the `ProxiedQuery` should be
|
61
|
+
# constructed
|
62
|
+
# @return [ProxiedQuery]
|
63
|
+
def initialize(id:, text:, supported:, count:)
|
64
|
+
@id = id
|
65
|
+
@text = text
|
66
|
+
@supported = supported
|
67
|
+
@count = count
|
68
|
+
end
|
69
|
+
|
70
|
+
# Checks two proxied queries for equality by comparing all of their attributes.
|
71
|
+
#
|
72
|
+
# @param [ProxiedQuery] the query against which `self` should be compared
|
73
|
+
# @return [Boolean]
|
74
|
+
def ==(other)
|
75
|
+
id == other.id &&
|
76
|
+
text == other.text &&
|
77
|
+
supported == other.supported
|
78
|
+
end
|
79
|
+
|
80
|
+
# Creates a cache on ReadySet for this query.
|
81
|
+
#
|
82
|
+
# @param [String] name the name for the cache being created
|
83
|
+
# @param [Boolean] always whether the cache should always be used. if this is true, queries
|
84
|
+
# to these caches will never fall back to the database
|
85
|
+
# @return [CachedQuery] the newly-cached query
|
86
|
+
# @raise [ProxiedQuery::UnsupportedError] raised if this method is invoked on an
|
87
|
+
# unsupported query
|
88
|
+
def cache!(name: nil, always: false)
|
89
|
+
if supported == :unsupported
|
90
|
+
raise UnsupportedError, id
|
91
|
+
else
|
92
|
+
Readyset.create_cache!(id: id, name: name, always: always)
|
93
|
+
CachedQuery.find(id)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
# Constructs a new `ProxiedQuery` from the given attributes. The attributes accepted
|
100
|
+
# by this method of this hash should correspond to the columns in the results returned by the
|
101
|
+
# `SHOW PROXIED QUERIES` ReadySet SQL extension.
|
102
|
+
#
|
103
|
+
# @param [Hash] attributes the attributes from which the `ProxiedQuery` should be constructed
|
104
|
+
# @return [ProxiedQuery]
|
105
|
+
def self.from_readyset_result(**attributes)
|
106
|
+
new(
|
107
|
+
id: attributes[:'query id'],
|
108
|
+
text: attributes[:'proxied query'],
|
109
|
+
supported: attributes[:'readyset supported'].to_sym,
|
110
|
+
count: attributes[:count].to_i,
|
111
|
+
)
|
112
|
+
end
|
113
|
+
private_class_method :from_readyset_result
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Readyset
|
2
|
+
module Query
|
3
|
+
module Queryable
|
4
|
+
private
|
5
|
+
|
6
|
+
def all(query) # :nodoc:
|
7
|
+
Readyset.raw_query(query).map do |result|
|
8
|
+
from_readyset_result(**result.symbolize_keys)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def find(query, id) # :nodoc:
|
13
|
+
result = Readyset.raw_query_sanitize(query, id).first
|
14
|
+
|
15
|
+
if result.nil?
|
16
|
+
raise NotFoundError, id
|
17
|
+
else
|
18
|
+
from_readyset_result(**result.to_h.symbolize_keys)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# lib/readyset/query.rb
|
2
|
+
|
3
|
+
require 'active_model'
|
4
|
+
|
5
|
+
module Readyset
|
6
|
+
module Query
|
7
|
+
class BaseError < StandardError
|
8
|
+
attr_reader :id
|
9
|
+
|
10
|
+
def initialize(id)
|
11
|
+
@id = id
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# An error raised when a query with the given ID can't be found on the ReadySet
|
16
|
+
# instance.
|
17
|
+
class NotFoundError < BaseError
|
18
|
+
def to_s
|
19
|
+
"Query not found for ID #{id}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# lib/readyset/railtie.rb
|
2
|
+
|
3
|
+
require 'active_record/readyset_connection_handling'
|
4
|
+
|
5
|
+
module Readyset
|
6
|
+
class Railtie < Rails::Railtie
|
7
|
+
initializer 'readyset.action_controller' do
|
8
|
+
ActiveSupport.on_load(:action_controller) do
|
9
|
+
prepend Readyset::ControllerExtension
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
initializer 'readyset.active_record' do |app|
|
14
|
+
ActiveSupport.on_load(:active_record) do
|
15
|
+
ActiveRecord::Base.prepend(Readyset::ModelExtension)
|
16
|
+
ActiveRecord::Base.extend(ActiveRecord::ReadysetConnectionHandling)
|
17
|
+
|
18
|
+
ActiveRecord::Relation.prepend(Readyset::RelationExtension)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# This Railtie sets up the ReadySet connection pools, which prevents users from needing to
|
23
|
+
# add a call to `ActiveRecord::Base.connects_to` in their ApplicationRecord class.
|
24
|
+
initializer 'readyset.connection_pools' do |app|
|
25
|
+
ActiveSupport.on_load(:after_initialize) do
|
26
|
+
shard = Readyset.config.shard
|
27
|
+
|
28
|
+
ActiveRecord::Base.connected_to(role: ActiveRecord.reading_role, shard: shard) do
|
29
|
+
ActiveRecord::Base.establish_connection(:readyset)
|
30
|
+
end
|
31
|
+
|
32
|
+
ActiveRecord::Base.connected_to(role: ActiveRecord.writing_role, shard: shard) do
|
33
|
+
ActiveRecord::Base.establish_connection(:readyset)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
rake_tasks do
|
39
|
+
Dir[File.join(File.dirname(__FILE__), '../tasks/*.rake')].each { |f| load f }
|
40
|
+
end
|
41
|
+
|
42
|
+
initializer 'readyset.query_annotator' do |app|
|
43
|
+
setup_query_annotator
|
44
|
+
end
|
45
|
+
|
46
|
+
def setup_query_annotator
|
47
|
+
config.after_initialize do
|
48
|
+
if Rails.env.development? || Rails.env.test?
|
49
|
+
if Rails.configuration.active_record.query_log_tags_enabled
|
50
|
+
Rails.configuration.active_record.query_log_tags ||= []
|
51
|
+
Rails.configuration.active_record.query_log_tags << {
|
52
|
+
destination: ->(context) do
|
53
|
+
ActiveRecord::Base.connection_db_config.name
|
54
|
+
end,
|
55
|
+
}
|
56
|
+
else
|
57
|
+
Rails.logger.warn 'Query log tags are currently disabled.' \
|
58
|
+
'The ReadySet gem uses these tags to display information' \
|
59
|
+
'in the logs about whether a query was routed to ReadySet.' \
|
60
|
+
'It is highly recommended that you enable query log tags by setting' \
|
61
|
+
'`Rails.configuration.active_record.query_log_tags_enabled` to true to' \
|
62
|
+
'verify that queries are being routed to ReadySet as expected.'
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Readyset
|
2
|
+
module RelationExtension
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
prepended do
|
6
|
+
# Creates a new cache on ReadySet for this query. This method is a no-op if a cache for the
|
7
|
+
# query already exists.
|
8
|
+
#
|
9
|
+
# NOTE: If the ActiveRecord query eager loads associations (e.g. via `#includes`), the
|
10
|
+
# the queries issued to do the eager loading will not have caches created. Those queries must
|
11
|
+
# have their caches created separately.
|
12
|
+
#
|
13
|
+
# @param always [Boolean] whether the queries to this cache should always be served by
|
14
|
+
# ReadySet, preventing fallback to the uptream database
|
15
|
+
# @return [void]
|
16
|
+
def create_readyset_cache!(always: false)
|
17
|
+
Readyset.create_cache!(to_sql, always: always)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Gets information about this query from ReadySet, including the query's ID, the normalized
|
21
|
+
# query text, and whether the query is supported by ReadySet.
|
22
|
+
#
|
23
|
+
# @return [Readyset::Explain]
|
24
|
+
def readyset_explain
|
25
|
+
Readyset.explain(to_sql)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|