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.
@@ -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