Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

relation: Add .readyset_explain #62

Merged
merged 2 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ Gemfile.lock
spec/internal/tmp/
spec/internal/config/storage.yml
spec/internal/db/*.sqlite
spec/internal/db/*.sqlite-shm
spec/internal/db/*.sqlite-wal
16 changes: 15 additions & 1 deletion lib/readyset.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# lib/readyset.rb

require 'readyset/caches'
require 'readyset/configuration'
require 'readyset/controller_extension'
require 'readyset/model_extension'
require 'readyset/explain'
require 'readyset/query'
require 'readyset/query/cached_query'
require 'readyset/query/proxied_query'
Expand Down Expand Up @@ -83,12 +85,24 @@ def self.drop_cache!(name_or_id: nil, sql: nil)
nil
end

# Gets information about the given query from ReadySet, including whether it's supported to be
# cached, its current status, the rewritten query text, and the query ID.
#
# The information about the given query is retrieved by invoking `EXPLAIN CREATE CACHE FROM` on
# ReadySet.
#
# @param [String] a query about which information should be retrieved
# @return [Explain]
def self.explain(query)
Explain.call(query)
end

# Executes a raw SQL query against ReadySet. The query is sanitized prior to being executed.
# @note This method is not part of the public API.
# @param sql_array [Array<Object>] the SQL array to be executed against ReadySet.
# @return [PG::Result] the result of executing the SQL query.
def self.raw_query(*sql_array) # :nodoc:
ActiveRecord::Base.connected_to(role: reading_role, shard: shard, prevent_writes: false) do
ActiveRecord::Base.connected_to(role: writing_role, shard: shard, prevent_writes: false) do
ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql_array(sql_array))
end
end
Expand Down
21 changes: 21 additions & 0 deletions lib/readyset/caches.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module Readyset
# Defines the DSL used in the gem's "migration" files. The DSL should be used by inheriting
# from this class and invoking the `.cache` class method to define new caches.
class Caches
ethan-readyset marked this conversation as resolved.
Show resolved Hide resolved
class << self
attr_reader :caches
end

def self.cache(id:, always: false)
helpotters marked this conversation as resolved.
Show resolved Hide resolved
@caches ||= Set.new

query = yield

@caches << Query::CachedQuery.new(
id: id,
text: query.strip,
always: always,
)
end
end
end
3 changes: 2 additions & 1 deletion lib/readyset/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

module Readyset
class Configuration
attr_accessor :shard
attr_accessor :migration_path, :shard

def initialize
@migration_path = File.join(Rails.root, 'db/readyset_caches.rb')
@shard = :readyset
end
end
Expand Down
60 changes: 60 additions & 0 deletions lib/readyset/explain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
module Readyset
# Represents the result of an `EXPLAIN CREATE CACHE` invocation on ReadySet.
class Explain
attr_reader :id, :text, :supported

# Gets information about the given query from ReadySet, including whether it's supported to be
# cached, its current status, the rewritten query text, and the query ID.
#
# The information about the given query is retrieved by invoking `EXPLAIN CREATE CACHE FROM` on
# ReadySet.
ethan-readyset marked this conversation as resolved.
Show resolved Hide resolved
#
# @param [String] a query about which information should be retrieved
# @return [Explain]
def self.call(query)
raw_results = Readyset.raw_query('EXPLAIN CREATE CACHE FROM %s', query)
from_readyset_results(**raw_results.first.to_h.symbolize_keys)
end

# Creates a new `Explain` with the given attributes.
#
# @param [String] id the ID of the query
# @param [String] text the query text
# @param [Symbol] supported the supported status of the query
# @return [Explain]
def initialize(id:, text:, supported:) # :nodoc:
@id = id
@text = text
@supported = supported
end

# Compares `self` with another `Explain` by comparing them attribute-wise.
#
# @param [Explain] other the `Explain` to which `self` should be compared
# @return [Boolean]
def ==(other)
id == other.id &&
text == other.text &&
supported == other.supported
end
helpotters marked this conversation as resolved.
Show resolved Hide resolved

# Returns true if the explain information returned by ReadySet indicates that the query is
# unsupported.
#
# @return [Boolean]
def unsupported?
supported == :unsupported
end

private

def self.from_readyset_results(**attributes)
new(
id: attributes[:'query id'],
text: attributes[:query],
supported: attributes[:'readyset supported'].to_sym,
)
end
private_class_method :from_readyset_results
end
end
2 changes: 1 addition & 1 deletion lib/readyset/query/cached_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def self.find(id)
# @param [Hash] attributes the attributes from which the `CachedQuery` should be
# constructed
# @return [CachedQuery]
def initialize(id:, text:, name:, always:, count:)
def initialize(id: nil, text:, name: nil, always: nil, count: nil)
@id = id
@text = text
@name = name
Expand Down
4 changes: 4 additions & 0 deletions lib/readyset/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ class Railtie < Rails::Railtie
ActiveRecord::Relation.prepend(Readyset::RelationExtension)
end
end

rake_tasks do
Dir[File.join(File.dirname(__FILE__), '../tasks/*.rake')].each { |f| load f }
end
end
end
12 changes: 10 additions & 2 deletions lib/readyset/relation_extension.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module RelationExtension
# query already exists.
#
# NOTE: If the ActiveRecord query eager loads associations (e.g. via `#includes`), the
# the queries issues to do the eager loading will not have caches created. Those queries must
# the queries issued to do the eager loading will not have caches created. Those queries must
# have their caches created separately.
#
# @return [void]
Expand All @@ -19,13 +19,21 @@ def create_readyset_cache!
# for the query already doesn't exist.
#
# NOTE: If the ActiveRecord query eager loads associations (e.g. via `#includes`), the
# the queries issues to do the eager loading will not have caches dropped. Those queries must
# the queries issued to do the eager loading will not have caches dropped. Those queries must
# have their caches dropped separately.
#
# @return [void]
def drop_readyset_cache!
Readyset.drop_cache!(sql: to_sql)
end

# Gets information about this query from ReadySet, including the query's ID, the normalized
# query text, and whether the query is supported by ReadySet.
#
# @return [Readyset::Explain]
def readyset_explain
Readyset.explain(to_sql)
end
end
end
end
75 changes: 75 additions & 0 deletions lib/tasks/readyset.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
require 'colorize'
require 'erb'
require 'progressbar'

namespace :readyset do
namespace :caches do
desc 'Dumps the set of caches that currently exist on ReadySet to a file'
task dump: :environment do
Rails.application.eager_load!

template = File.read(File.join(File.dirname(__FILE__), '../templates/caches.rb.tt'))

queries = Readyset::Query::CachedQuery.all
f = File.new(Readyset.configuration.migration_path, 'w')
f.write(ERB.new(template, trim_mode: '-').result(binding))
f.close
end

desc 'Synchronizes the caches on ReadySet such that the caches on ReadySet match those ' \
'listed in db/readyset_caches.rb'
task migrate: :environment do
Rails.application.eager_load!

file = Readyset.configuration.migration_path

# We load the definition of the `Readyset::Caches` subclass in the context of a
# container object so we can be sure that we are never re-opening a previously-defined
# subclass of `Readyset::Caches`. When the container object is garbage collected, the
# definition of the `Readyset::Caches` subclass is garbage collected too
container = Object.new
container.instance_eval(File.read(file))
caches = container.singleton_class::ReadysetCaches.caches

caches_on_readyset = Readyset::Query::CachedQuery.all.index_by(&:id)
caches_on_readyset_ids = caches_on_readyset.keys.to_set

caches_in_migration_file = caches.index_by(&:id)
caches_in_migration_file_ids = caches_in_migration_file.keys.to_set

to_drop_ids = caches_on_readyset_ids - caches_in_migration_file_ids
to_create_ids = caches_in_migration_file_ids - caches_on_readyset_ids

if to_drop_ids.size.positive? || to_create_ids.size.positive?
dropping = 'Dropping'.red
creating = 'creating'.green
print "#{dropping} #{to_drop_ids.size} caches and #{creating} #{to_create_ids.size} " \
'caches. Continue? (y/n) '
$stdout.flush
y_or_n = STDIN.gets.strip

if y_or_n == 'y'
if to_drop_ids.size.positive?
bar = ProgressBar.create(title: 'Dropping caches', total: to_drop_ids.size)

to_drop_ids.each do |id|
bar.increment
Readyset.drop_cache!(name_or_id: id)
end
end

if to_create_ids.size.positive?
bar = ProgressBar.create(title: 'Creating caches', total: to_create_ids.size)

to_create_ids.each do |id|
bar.increment
Readyset.create_cache!(id: id)
end
end
end
else
puts 'Nothing to do'
end
end
end
end
11 changes: 11 additions & 0 deletions lib/templates/caches.rb.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class ReadysetCaches < Readyset::Caches
<% queries.each do |query| -%>
cache id: <%= query.id.dump %>, always: <%= query.always %> do
<<~SQL
<%= query.text.gsub("\n", "\n ") %>
SQL
end

<%- end -%>
end

2 changes: 2 additions & 0 deletions readyset.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Gem::Specification.new do |spec|
spec.add_dependency 'actionpack', '>= 6.1'
spec.add_dependency 'activerecord', '>= 6.1'
spec.add_dependency 'activesupport', '>= 6.1'
spec.add_dependency 'colorize', '~> 1.1'
spec.add_dependency 'progressbar', '~> 1.13'
spec.add_dependency 'rake', '~> 13.0'

spec.add_development_dependency 'combustion', '~> 1.3'
Expand Down
40 changes: 40 additions & 0 deletions spec/caches_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
RSpec.describe Readyset::Caches do
describe '.cache' do
after(:each) do
Readyset::Caches.instance_variable_set(:@caches, nil)
end

it 'adds a cache with the given attributes to the @caches ivar' do
query = build(:cached_query, always: true, count: nil, name: nil)

Readyset::Caches.cache(always: true, id: query.id) { query.text }

caches = Readyset::Caches.instance_variable_get(:@caches)
expect(caches.size).to eq(1)
expect(caches.first).to eq(query)
end

context 'when no always parameter is passed' do
it 'defaults the always parameter to false' do
query = build(:cached_query, count: nil, name: nil)

Readyset::Caches.cache(id: query.id) { query.text }

always = Readyset::Caches.instance_variable_get(:@caches).first.always
expect(always).to eq(false)
end
end
end

describe '.caches' do
it 'returns the caches stored in the @caches ivar' do
query = build(:cached_query, count: nil, name: nil)
Readyset::Caches.cache(always: query.always, id: query.id) { query.text }

result = Readyset::Caches.caches

expect(result.size).to eq(1)
expect(result.first).to eq(query)
end
end
end
7 changes: 7 additions & 0 deletions spec/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,12 @@
config = Readyset::Configuration.new
expect(config.shard).to eq(:readyset)
end

it 'initializes migration_path to be db/readyset_caches.rb' do
config = Readyset::Configuration.new

expected = File.join(Rails.root, 'db/readyset_caches.rb')
expect(config.migration_path).to eq(expected)
end
end
end
Loading