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,58 @@
|
|
1
|
+
module Readyset
|
2
|
+
module Utils
|
3
|
+
# Keeps track of events that occur over time to see if the number of logged events exceeds a
|
4
|
+
# preconfigured threshold in a preconfigured window of time. For example, if `window_size` is
|
5
|
+
# 10 and `time_period` is 1 minute, the number of events logged in the last minute must exceed
|
6
|
+
# 10 in order for `WindowCounter#threshold_crossed?` to return true.
|
7
|
+
class WindowCounter
|
8
|
+
def initialize(window_size: 10, time_period: 1.minute)
|
9
|
+
@lock = Mutex.new
|
10
|
+
@time_period = time_period
|
11
|
+
@times = []
|
12
|
+
@window_size = window_size
|
13
|
+
end
|
14
|
+
|
15
|
+
delegate :clear, to: :times
|
16
|
+
|
17
|
+
# Logs a new event
|
18
|
+
def log
|
19
|
+
lock.synchronize do
|
20
|
+
remove_times_out_of_threshold!
|
21
|
+
times << Time.zone.now
|
22
|
+
end
|
23
|
+
|
24
|
+
nil
|
25
|
+
end
|
26
|
+
|
27
|
+
# Returns the current number of events logged in the configured `time_period`
|
28
|
+
#
|
29
|
+
# @return [Integer]
|
30
|
+
def size
|
31
|
+
lock.synchronize do
|
32
|
+
remove_times_out_of_threshold!
|
33
|
+
times.size
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns true only if the number of events logged in the configured `time_period` has
|
38
|
+
# exceeded the configured `window_size`.
|
39
|
+
#
|
40
|
+
# @return [Boolean]
|
41
|
+
def threshold_crossed?
|
42
|
+
lock.synchronize do
|
43
|
+
remove_times_out_of_threshold!
|
44
|
+
times.size > window_size
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
attr_reader :lock, :time_period, :times, :window_size
|
51
|
+
|
52
|
+
def remove_times_out_of_threshold!
|
53
|
+
times.select! { |time| time >= time_period.ago }
|
54
|
+
nil
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
data/lib/readyset.rb
ADDED
@@ -0,0 +1,168 @@
|
|
1
|
+
# lib/readyset.rb
|
2
|
+
|
3
|
+
require 'active_record/connection_adapters/readyset_adapter'
|
4
|
+
require 'readyset/caches'
|
5
|
+
require 'readyset/configuration'
|
6
|
+
require 'readyset/controller_extension'
|
7
|
+
require 'readyset/health/healthchecker'
|
8
|
+
require 'readyset/model_extension'
|
9
|
+
require 'readyset/explain'
|
10
|
+
require 'readyset/query'
|
11
|
+
require 'readyset/query/cached_query'
|
12
|
+
require 'readyset/query/proxied_query'
|
13
|
+
require 'readyset/railtie' if defined?(Rails::Railtie)
|
14
|
+
require 'readyset/relation_extension'
|
15
|
+
require 'readyset/utils/window_counter'
|
16
|
+
|
17
|
+
# The Readyset module provides functionality to integrate ReadySet caching
|
18
|
+
# with Ruby on Rails applications.
|
19
|
+
# It offers methods to configure and manage ReadySet caches,
|
20
|
+
# as well as to route database queries through ReadySet.
|
21
|
+
module Readyset
|
22
|
+
# Sets the configuration for Readyset.
|
23
|
+
# @!attribute [w] configuration
|
24
|
+
attr_writer :configuration
|
25
|
+
|
26
|
+
# Retrieves the Readyset configuration, initializing it if it hasn't been set yet.
|
27
|
+
# @return [Readyset::Configuration] the current configuration for Readyset.
|
28
|
+
def self.configuration
|
29
|
+
@configuration ||= Configuration.new
|
30
|
+
end
|
31
|
+
|
32
|
+
class << self
|
33
|
+
alias_method :config, :configuration
|
34
|
+
end
|
35
|
+
|
36
|
+
# Configures Readyset by providing a block with configuration details.
|
37
|
+
# @yieldparam [Readyset::Configuration] configuration the current configuration instance.
|
38
|
+
# @yieldreturn [void]
|
39
|
+
def self.configure
|
40
|
+
yield configuration
|
41
|
+
end
|
42
|
+
|
43
|
+
# Creates a new cache on ReadySet using the given ReadySet query ID or SQL query.
|
44
|
+
# @param id [String] the ReadySet query ID of the query from which a cache should be created.
|
45
|
+
# @param sql [String] the SQL string from which a cache should be created.
|
46
|
+
# @param name [String] the name for the cache being created.
|
47
|
+
# @param always [Boolean] whether the cache should always be used;
|
48
|
+
# queries to these caches will never fall back to the database if this is true.
|
49
|
+
# @return [void]
|
50
|
+
# @raise [ArgumentError] raised if exactly one of the `id` or `sql` arguments was not provided.
|
51
|
+
def self.create_cache!(id: nil, sql: nil, name: nil, always: false)
|
52
|
+
if (sql.nil? && id.nil?) || (!sql.nil? && !id.nil?)
|
53
|
+
raise ArgumentError, 'Exactly one of the `id` and `sql` parameters must be provided'
|
54
|
+
end
|
55
|
+
|
56
|
+
connection = Readyset.route { ActiveRecord::Base.connection }
|
57
|
+
from =
|
58
|
+
if sql
|
59
|
+
sql
|
60
|
+
else
|
61
|
+
connection.quote_column_name(id)
|
62
|
+
end
|
63
|
+
|
64
|
+
if always && name
|
65
|
+
quoted_name = connection.quote_column_name(name)
|
66
|
+
raw_query("CREATE CACHE ALWAYS #{quoted_name} FROM #{from}")
|
67
|
+
elsif always
|
68
|
+
raw_query("CREATE CACHE ALWAYS FROM #{from}")
|
69
|
+
elsif name
|
70
|
+
quoted_name = connection.quote_column_name(name)
|
71
|
+
raw_query("CREATE CACHE #{quoted_name} FROM #{from}")
|
72
|
+
else
|
73
|
+
raw_query("CREATE CACHE FROM #{from}")
|
74
|
+
end
|
75
|
+
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
|
79
|
+
# Drops an existing cache on ReadySet using the given query name.
|
80
|
+
# @param name [String] the name of the cache that should be dropped.
|
81
|
+
# @return [void]
|
82
|
+
def self.drop_cache!(name)
|
83
|
+
connection = Readyset.route { ActiveRecord::Base.connection }
|
84
|
+
quoted_name = connection.quote_column_name(name)
|
85
|
+
raw_query("DROP CACHE #{quoted_name}")
|
86
|
+
|
87
|
+
nil
|
88
|
+
end
|
89
|
+
|
90
|
+
# Gets information about the given query from ReadySet, including whether it's supported to be
|
91
|
+
# cached, its current status, the rewritten query text, and the query ID.
|
92
|
+
#
|
93
|
+
# The information about the given query is retrieved by invoking `EXPLAIN CREATE CACHE FROM` on
|
94
|
+
# ReadySet.
|
95
|
+
#
|
96
|
+
# @param [String] a query about which information should be retrieved
|
97
|
+
# @return [Explain]
|
98
|
+
def self.explain(query)
|
99
|
+
Explain.call(query)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Executes a raw SQL query against ReadySet. The query is sanitized prior to being executed.
|
103
|
+
# @note This method is not part of the public API.
|
104
|
+
# @param sql_array [Array<Object>] the SQL array to be executed against ReadySet.
|
105
|
+
# @return [PG::Result] the result of executing the SQL query.
|
106
|
+
def self.raw_query_sanitize(*sql_array) # :nodoc:
|
107
|
+
raw_query(ActiveRecord::Base.sanitize_sql_array(sql_array))
|
108
|
+
end
|
109
|
+
|
110
|
+
def self.raw_query(query) # :nodoc:
|
111
|
+
ActiveRecord::Base.connected_to(role: writing_role, shard: shard, prevent_writes: false) do
|
112
|
+
ActiveRecord::Base.connection.execute(query)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Routes to ReadySet any queries that occur in the given block.
|
117
|
+
# @param prevent_writes [Boolean] if true, prevents writes from being executed on
|
118
|
+
# the connection to ReadySet.
|
119
|
+
# @yield a block whose queries should be routed to ReadySet.
|
120
|
+
# @return the value of the last line of the block.
|
121
|
+
def self.route(prevent_writes: true, &block)
|
122
|
+
if healthchecker.healthy?
|
123
|
+
begin
|
124
|
+
if prevent_writes
|
125
|
+
ActiveRecord::Base.connected_to(role: reading_role, shard: shard, prevent_writes: true,
|
126
|
+
&block)
|
127
|
+
else
|
128
|
+
ActiveRecord::Base.connected_to(role: writing_role, shard: shard, prevent_writes: false,
|
129
|
+
&block)
|
130
|
+
end
|
131
|
+
rescue => e
|
132
|
+
healthchecker.process_exception(e)
|
133
|
+
raise e
|
134
|
+
end
|
135
|
+
else
|
136
|
+
yield
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
# Delegates the shard method to the configuration.
|
143
|
+
class << self
|
144
|
+
private(*delegate(:shard, to: :configuration))
|
145
|
+
end
|
146
|
+
|
147
|
+
def self.healthchecker
|
148
|
+
@healthchecker ||= Readyset::Health::Healthchecker.new(
|
149
|
+
config.failover,
|
150
|
+
shard: shard,
|
151
|
+
)
|
152
|
+
end
|
153
|
+
private_class_method :healthchecker
|
154
|
+
|
155
|
+
# Returns the reading role for ActiveRecord connections.
|
156
|
+
# @return [Symbol] the reading role.
|
157
|
+
def self.reading_role
|
158
|
+
ActiveRecord.reading_role
|
159
|
+
end
|
160
|
+
private_class_method :reading_role
|
161
|
+
|
162
|
+
# Returns the writing role for ActiveRecord connections.
|
163
|
+
# @return [Symbol] the writing role.
|
164
|
+
def self.writing_role
|
165
|
+
ActiveRecord.writing_role
|
166
|
+
end
|
167
|
+
private_class_method :writing_role
|
168
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
require 'colorize'
|
2
|
+
require 'erb'
|
3
|
+
require 'progressbar'
|
4
|
+
require 'terminal-table'
|
5
|
+
|
6
|
+
namespace :readyset do
|
7
|
+
desc 'Creates a cache from the given query ID'
|
8
|
+
task :create_cache, [:id] => :environment do |_, args|
|
9
|
+
if args.key?(:id)
|
10
|
+
Readyset.create_cache!(id: args[:id])
|
11
|
+
else
|
12
|
+
Rails.logger.error 'A query ID must be passed to this task'
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
desc 'Creates a cache from the given query ID whose queries will never fall back to the ' \
|
17
|
+
'primary database'
|
18
|
+
task :create_cache_always, [:id] => :environment do |_, args|
|
19
|
+
if args.key?(:id)
|
20
|
+
Readyset.create_cache!(id: args[:id], always: true)
|
21
|
+
else
|
22
|
+
Rails.logger.error 'A query ID must be passed to this task'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
desc 'Prints a list of all the queries that ReadySet has proxied'
|
27
|
+
task proxied_queries: :environment do
|
28
|
+
rows = Readyset::Query::ProxiedQuery.all.map do |q|
|
29
|
+
[q.id, q.text, q.supported, q.count]
|
30
|
+
end
|
31
|
+
table = Terminal::Table.new(headings: [:id, :text, :supported, :count], rows: rows)
|
32
|
+
|
33
|
+
Rails.logger.info table.to_s
|
34
|
+
end
|
35
|
+
|
36
|
+
namespace :proxied_queries do
|
37
|
+
desc 'Creates caches for all of the supported queries on ReadySet'
|
38
|
+
task cache_all_supported: :environment do
|
39
|
+
Readyset::Query::ProxiedQuery.cache_all_supported!
|
40
|
+
end
|
41
|
+
|
42
|
+
desc 'Clears the list of proxied queries on ReadySet'
|
43
|
+
task drop_all: :environment do
|
44
|
+
Readyset.raw_query('DROP ALL PROXIED QUERIES'.freeze)
|
45
|
+
end
|
46
|
+
|
47
|
+
desc 'Prints a list of all the queries that ReadySet has proxied that can be cached'
|
48
|
+
task supported: :environment do
|
49
|
+
rows = Readyset::Query::ProxiedQuery.all.
|
50
|
+
select { |query| query.supported == :yes }.
|
51
|
+
map { |q| [q.id, q.text, q.count] }
|
52
|
+
table = Terminal::Table.new(headings: [:id, :text, :count], rows: rows)
|
53
|
+
|
54
|
+
Rails.logger.info table.to_s
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
desc 'Prints a list of all the cached queries on ReadySet'
|
59
|
+
task caches: :environment do
|
60
|
+
rows = Readyset::Query::CachedQuery.all.map do |q|
|
61
|
+
[q.id, q.name, q.text, q.always, q.count]
|
62
|
+
end
|
63
|
+
table = Terminal::Table.new(headings: [:id, :name, :text, :always, :count], rows: rows)
|
64
|
+
|
65
|
+
Rails.logger.info table.to_s
|
66
|
+
end
|
67
|
+
|
68
|
+
namespace :caches do
|
69
|
+
desc 'Drops the cache with the given name'
|
70
|
+
task :drop, [:name] => :environment do |_, args|
|
71
|
+
if args.key?(:name)
|
72
|
+
Readyset.drop_cache!(args[:name])
|
73
|
+
else
|
74
|
+
Rails.logger.error 'A cache name must be passed to this task'
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
desc 'Drops all the caches on ReadySet'
|
79
|
+
task drop_all: :environment do
|
80
|
+
Readyset::Query::CachedQuery.drop_all!
|
81
|
+
end
|
82
|
+
|
83
|
+
desc 'Dumps the set of caches that currently exist on ReadySet to a file'
|
84
|
+
task dump: :environment do
|
85
|
+
template = File.read(File.join(File.dirname(__FILE__), '../templates/caches.rb.tt'))
|
86
|
+
|
87
|
+
queries = Readyset::Query::CachedQuery.all
|
88
|
+
|
89
|
+
f = File.new(Readyset.configuration.migration_path, 'w')
|
90
|
+
f.write(ERB.new(template, trim_mode: '-').result(binding))
|
91
|
+
f.close
|
92
|
+
end
|
93
|
+
|
94
|
+
desc 'Synchronizes the caches on ReadySet such that the caches on ReadySet match those ' \
|
95
|
+
'listed in db/readyset_caches.rb'
|
96
|
+
task migrate: :environment do
|
97
|
+
file = Readyset.configuration.migration_path
|
98
|
+
|
99
|
+
# We load the definition of the `Readyset::Caches` subclass in the context of a
|
100
|
+
# container object so we can be sure that we are never re-opening a previously-defined
|
101
|
+
# subclass of `Readyset::Caches`. When the container object is garbage collected, the
|
102
|
+
# definition of the `Readyset::Caches` subclass is garbage collected too
|
103
|
+
container = Object.new
|
104
|
+
container.instance_eval(File.read(file))
|
105
|
+
caches_in_migration_file = container.singleton_class::ReadysetCaches.caches.index_by(&:text)
|
106
|
+
caches_on_readyset = Readyset::Query::CachedQuery.all.index_by(&:text)
|
107
|
+
|
108
|
+
to_drop = caches_on_readyset.keys - caches_in_migration_file.keys
|
109
|
+
to_create = caches_in_migration_file.keys - caches_on_readyset.keys
|
110
|
+
|
111
|
+
if to_drop.size.positive? || to_create.size.positive?
|
112
|
+
dropping = 'Dropping'.red
|
113
|
+
creating = 'creating'.green
|
114
|
+
print "#{dropping} #{to_drop.size} caches and #{creating} #{to_create.size} caches. " \
|
115
|
+
'Continue? (y/n) '
|
116
|
+
$stdout.flush
|
117
|
+
y_or_n = STDIN.gets.strip
|
118
|
+
|
119
|
+
if y_or_n == 'y'
|
120
|
+
if to_drop.size.positive?
|
121
|
+
bar = ProgressBar.create(title: 'Dropping caches', total: to_drop.size)
|
122
|
+
|
123
|
+
to_drop.each do |text|
|
124
|
+
bar.increment
|
125
|
+
Readyset.drop_cache!(caches_on_readyset[text].name)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
if to_create.size.positive?
|
130
|
+
bar = ProgressBar.create(title: 'Creating caches', total: to_create.size)
|
131
|
+
|
132
|
+
to_create.each do |text|
|
133
|
+
bar.increment
|
134
|
+
cache = caches_in_migration_file[text]
|
135
|
+
Readyset.create_cache!(sql: text, always: cache.always)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
else
|
140
|
+
Rails.logger.info 'Nothing to do'
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
desc 'Prints status information about ReadySet'
|
146
|
+
task status: :environment do
|
147
|
+
rows = Readyset.raw_query('SHOW READYSET STATUS'.freeze).
|
148
|
+
map { |result| [result['name'], result['value']] }
|
149
|
+
table = Terminal::Table.new(rows: rows)
|
150
|
+
|
151
|
+
Rails.logger.info table.to_s
|
152
|
+
end
|
153
|
+
|
154
|
+
desc 'Prints information about the tables known to ReadySet'
|
155
|
+
task tables: :environment do
|
156
|
+
rows = Readyset.raw_query('SHOW READYSET TABLES'.freeze).
|
157
|
+
map { |result| [result['table'], result['status'], result['description']] }
|
158
|
+
table = Terminal::Table.new(headings: [:table, :status, :description], rows: rows)
|
159
|
+
|
160
|
+
Rails.logger.info table.to_s
|
161
|
+
end
|
162
|
+
end
|
data/readyset.gemspec
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/readyset/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'readyset'
|
7
|
+
spec.version = Readyset::VERSION
|
8
|
+
spec.authors = ['ReadySet Technology, Inc.']
|
9
|
+
spec.email = ['info@readyset.io']
|
10
|
+
spec.licenses = ['MIT']
|
11
|
+
|
12
|
+
spec.summary = 'A Rails adapter for ReadySet, a partially-stateful, incrementally-maintained ' \
|
13
|
+
'SQL cache.'
|
14
|
+
spec.description = 'This gem provides a Rails adapter to the ReadySet SQL cache.'
|
15
|
+
spec.homepage = 'https://readyset.io'
|
16
|
+
spec.required_ruby_version = '>= 3.0'
|
17
|
+
|
18
|
+
# spec.metadata['allowed_push_host'] = "TODO: Set to your gem server 'https://example.com'"
|
19
|
+
|
20
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
21
|
+
spec.metadata['source_code_uri'] = 'https://github.com/readysettech/readyset-rails'
|
22
|
+
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
25
|
+
spec.files = Dir.chdir(__dir__) do
|
26
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
27
|
+
(File.expand_path(f) == __FILE__) ||
|
28
|
+
f.start_with?(*%w(bin/ test/ spec/ features/ .git .github))
|
29
|
+
end
|
30
|
+
end
|
31
|
+
spec.bindir = 'exe'
|
32
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
33
|
+
spec.require_paths = ['lib']
|
34
|
+
|
35
|
+
spec.add_dependency 'actionpack', ['>= 6.1', '<= 7.1']
|
36
|
+
spec.add_dependency 'activerecord', ['>= 6.1', '<= 7.1']
|
37
|
+
spec.add_dependency 'activesupport', ['>= 6.1', '<= 7.1']
|
38
|
+
spec.add_dependency 'colorize', '~> 1.1'
|
39
|
+
spec.add_dependency 'concurrent-ruby', '~> 1.2'
|
40
|
+
spec.add_dependency 'progressbar', '~> 1.13'
|
41
|
+
spec.add_dependency 'rake', '~> 13.0'
|
42
|
+
spec.add_dependency 'terminal-table', '~> 3.0'
|
43
|
+
|
44
|
+
spec.add_development_dependency 'combustion', '~> 1.3'
|
45
|
+
spec.add_development_dependency 'factory_bot', '~> 6.4'
|
46
|
+
spec.add_development_dependency 'pg', '~> 1.5'
|
47
|
+
spec.add_development_dependency 'pry', '~> 0.14'
|
48
|
+
spec.add_development_dependency 'rspec', '~> 3.2'
|
49
|
+
spec.add_development_dependency 'rspec-rails', '~> 6.0'
|
50
|
+
spec.add_development_dependency 'rubocop-airbnb', '~> 6.0'
|
51
|
+
spec.add_development_dependency 'timecop', '~> 0.9'
|
52
|
+
end
|
data/sig/readyset.rbs
ADDED