Skip to content

Commit

Permalink
Make Active Record's query cache an LRU
Browse files Browse the repository at this point in the history
I don't know how prevalent this really is, but I heard several time
about users having memory exhaustion issues caused by the query cache
when dealing with long running jobs.

Overall it seems sensible for this cache not to be entirely unbounded.
  • Loading branch information
byroot committed May 7, 2023
1 parent 6d452aa commit 89a5d6a
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 18 deletions.
22 changes: 22 additions & 0 deletions activerecord/CHANGELOG.md
@@ -1,3 +1,25 @@
* Active Record query cache is now evicts least recently used entries

By default it only keeps the `50` most recently used queries.

The cache size can be configured via `database.yml`

```yaml
development:
adapter: mysql2
query_cache: 100
```

It can also be entirely disabled:

```yaml
development:
adapter: mysql2
query_cache: false
```

*Jean Boussier*

* Deprecate `check_pending!` in favor of `check_pending_migrations!`.

`check_pending!` will only check for pending migrations on the current database connection or the one passed in. This has been deprecated in favor of `check_pending_migrations!` which will find all pending migrations for the database configurations in a given environment.
Expand Down
Expand Up @@ -5,6 +5,8 @@
module ActiveRecord
module ConnectionAdapters # :nodoc:
module QueryCache
DEFAULT_SIZE = 100 # :nodoc:

class << self
def included(base) # :nodoc:
dirties_query_cache base, :exec_query, :execute, :create, :insert, :update, :delete, :truncate,
Expand Down Expand Up @@ -52,8 +54,9 @@ def query_cache_enabled

def initialize(*)
super
@query_cache = Hash.new { |h, sql| h[sql] = {} }
@query_cache = {}
@query_cache_enabled = false
@query_cache_max_size = nil
end

# Enable the query cache within the block.
Expand Down Expand Up @@ -114,31 +117,52 @@ def select_all(arel, name = nil, binds = [], preparable: nil, async: false) # :n

private
def lookup_sql_cache(sql, name, binds)
key = binds.empty? ? sql : [sql, binds]
hit = false
result = nil

@lock.synchronize do
if @query_cache[sql].key?(binds)
ActiveSupport::Notifications.instrument(
"sql.active_record",
cache_notification_info(sql, name, binds)
)
@query_cache[sql][binds]
if (result = @query_cache.delete(key))
hit = true
@query_cache[key] = result
end
end

if hit
ActiveSupport::Notifications.instrument(
"sql.active_record",
cache_notification_info(sql, name, binds)
)

result
end
end

def cache_sql(sql, name, binds)
key = binds.empty? ? sql : [sql, binds]
result = nil
hit = false

@lock.synchronize do
result =
if @query_cache[sql].key?(binds)
ActiveSupport::Notifications.instrument(
"sql.active_record",
cache_notification_info(sql, name, binds)
)
@query_cache[sql][binds]
else
@query_cache[sql][binds] = yield
if (result = @query_cache.delete(key))
hit = true
@query_cache[key] = result
else
result = @query_cache[key] = yield
if @query_cache_max_size && @query_cache.size > @query_cache_max_size
@query_cache.shift
end
result.dup
end
end

if hit
ActiveSupport::Notifications.instrument(
"sql.active_record",
cache_notification_info(sql, name, binds)
)
end

result.dup
end

# Database adapters can override this method to
Expand All @@ -155,7 +179,20 @@ def cache_notification_info(sql, name, binds)
end

def configure_query_cache!
enable_query_cache! if pool.query_cache_enabled
case query_cache = pool.db_config.query_cache
when 0, false
return
when Integer
@query_cache_max_size = query_cache
when nil
@query_cache_max_size = DEFAULT_SIZE
else
@query_cache_max_size = nil # no limit
end

if pool.query_cache_enabled
enable_query_cache!
end
end
end
end
Expand Down
Expand Up @@ -53,6 +53,10 @@ def max_queue
raise NotImplementedError
end

def query_cache
raise NotImplementedError
end

def checkout_timeout
raise NotImplementedError
end
Expand Down
Expand Up @@ -76,6 +76,10 @@ def max_threads
(configuration_hash[:max_threads] || pool).to_i
end

def query_cache
configuration_hash[:query_cache]
end

def max_queue
max_threads * 4
end
Expand Down
29 changes: 29 additions & 0 deletions activerecord/test/cases/query_cache_test.rb
Expand Up @@ -841,6 +841,35 @@ def test_cache_is_expired_by_habtm_delete
end
end

def test_query_cache_lru_eviction
connection = Post.connection
connection.pool.db_config.stub(:query_cache, 2) do
connection.send(:configure_query_cache!)
Post.cache do
assert_queries(2) do
connection.select_all("SELECT 1")
connection.select_all("SELECT 2")
connection.select_all("SELECT 1")
end

assert_queries(1) do
connection.select_all("SELECT 3")
connection.select_all("SELECT 3")
end

assert_no_queries do
connection.select_all("SELECT 1")
end

assert_queries(1) do
connection.select_all("SELECT 2")
end
end
end
ensure
connection.send(:configure_query_cache!)
end

test "threads use the same connection" do
@connection_1 = ActiveRecord::Base.connection.object_id

Expand Down
23 changes: 23 additions & 0 deletions guides/source/configuring.md
Expand Up @@ -3062,6 +3062,29 @@ development:
retry_deadline: 5 # Stop retrying queries after 5 seconds
```

#### Configuring Query Cache

By default, Rails automatically caches the result sets returned by queries. If Rails encounters the same query
again for that request or job, it will use the cached result set as opposed to running the query against
the database again.

The query cache is stored in memory, and to avoid using too much memory, it automatically evicts the least recently
used queries when reaching a threshold. By default the threshold is `100`, but can be configured in the `database.yml`.

```yaml
development:
adapter: mysql2
query_cache: 200
```

To entirely disable query caching, it can be set to `false`

```yaml
development:
adapter: mysql2
query_cache: false
```

### Creating Rails Environments

By default Rails ships with three environments: "development", "test", and "production". While these are sufficient for most use cases, there are circumstances when you want more environments.
Expand Down

0 comments on commit 89a5d6a

Please sign in to comment.