Permalink
Browse files

Redis cache store: avoid blocking the server in `#delete_matched`

Fixes #32610. Closes #32614.

Lua scripts in redis are *blocking*, meaning that no other client can
execute any commands while the script is running. See
https://redis.io/commands/eval#atomicity-of-scripts.

This results in the following exceptions once the number of keys is
sufficiently large:

    BUSY Redis is busy running a script.
    You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

This commit replaces the lua-based implementation with one that uses
`SCAN` and `DEL` in batches. This doesn't block the server.

The primary limitation of `SCAN`, i.e. potential duplicate keys, is of
no consequence here, because `DEL` ignores keys that do not exist.
  • Loading branch information...
glebm authored and jeremy committed Apr 17, 2018
1 parent 185fce1 commit ef2af628a9ec1cc4e7b6997a021dd3f85cfe4665
Showing with 18 additions and 6 deletions.
  1. +5 −0 activesupport/CHANGELOG.md
  2. +13 −6 activesupport/lib/active_support/cache/redis_cache_store.rb
@@ -1,3 +1,8 @@
* Redis cache store: `delete_matched` no longer blocks the Redis server.
(Switches from evaled Lua to a batched SCAN + DEL loop.)
*Gleb Mazovetskiy*
* Fix bug where `ActiveSupport::Cache` will massively inflate the storage
size when compression is enabled (which is true by default). This patch
does not attempt to repair existing data: please manually flush the cache
@@ -62,8 +62,9 @@ class RedisCacheStore < Store
end
end
DELETE_GLOB_LUA = "for i, name in ipairs(redis.call('KEYS', ARGV[1])) do redis.call('DEL', name); end"
private_constant :DELETE_GLOB_LUA
# The maximum number of entries to receive per SCAN call.
SCAN_BATCH_SIZE = 1000
private_constant :SCAN_BATCH_SIZE
# Support raw values in the local cache strategy.
module LocalCacheWithRaw # :nodoc:
@@ -231,12 +232,18 @@ def read_multi(*names)
# Failsafe: Raises errors.
def delete_matched(matcher, options = nil)
instrument :delete_matched, matcher do
case matcher
when String
redis.with { |c| c.eval DELETE_GLOB_LUA, [], [namespace_key(matcher, options)] }
else
unless String === matcher
raise ArgumentError, "Only Redis glob strings are supported: #{matcher.inspect}"
end
redis.with do |c|
pattern = namespace_key(matcher, options)
cursor = "0"
# Fetch keys in batches using SCAN to avoid blocking the Redis server.
begin
cursor, keys = c.scan(cursor, match: pattern, count: SCAN_BATCH_SIZE)
c.del(*keys) unless keys.empty?
end until cursor == "0"
end
end
end

0 comments on commit ef2af62

Please sign in to comment.