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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Readyset
4
+ VERSION = '0.1.1'
5
+ 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
@@ -0,0 +1,11 @@
1
+ class ReadysetCaches < Readyset::Caches
2
+ <% queries.each do |query| -%>
3
+ cache always: <%= query.always %> do
4
+ <<~SQL
5
+ <%= query.text.gsub("\n", "\n ") %>
6
+ SQL
7
+ end
8
+
9
+ <%- end -%>
10
+ end
11
+
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
@@ -0,0 +1,4 @@
1
+ module Readyset
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end