readyset 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,3 @@
1
+ module Readyset
2
+ module Error; end
3
+ 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