readyset 0.1.1
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.
- 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
|