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

Built-in Redis cache store #31134

Merged
merged 1 commit into from Nov 14, 2017
Merged

Built-in Redis cache store #31134

merged 1 commit into from Nov 14, 2017

Conversation

@jeremy
Copy link
Member

@jeremy jeremy commented Nov 12, 2017

  • Supports vanilla Redis, hiredis, and Redis::Distributed.
  • Supports Memcached-like sharding across Redises with Redis::Distributed.
  • Fault tolerant. If the Redis server is unavailable, no exceptions are raised. Cache fetches are treated as misses and writes are dropped.
  • Local cache. Hot in-memory primary cache within block/middleware scope.
  • read_/write_multi support for Redis mget/mset. Use Redis::Distributed 4.0.1+ for distributed mget support.
  • delete_matched support for Redis KEYS globs.
      # Defaults to `redis://localhost:6379/0`
      config.cache_store = :redis_cache_store

      # Supports all common cache store options (:namespace, :compress,
      # :compress_threshold, :expires_in, :race_condition_tool) and all
      # Redis options.
      cache_password = Rails.application.secrets.redis_cache_password
      config.cache_store = :redis_cache_store, driver: :hiredis,
        namespace: 'myapp-cache', compress: true, timeout: 1,
        url: "redis://:#{cache_password}@myapp-cache-1:6379/0"

      # Supports Redis::Distributed with multiple hosts
      config.cache_store = :redis_cache_store, driver: :hiredis
        namespace: 'myapp-cache', compress: true,
        url: %w[
          redis://myapp-cache-1:6379/0
          redis://myapp-cache-1:6380/0
          redis://myapp-cache-2:6379/0
          redis://myapp-cache-2:6380/0
          redis://myapp-cache-3:6379/0
          redis://myapp-cache-3:6380/0
        ]

      # Or pass a builder block
      config.cache_store = :redis_cache_store,
        namespace: 'myapp-cache', compress: true,
        redis: -> { Redis.new  }

Deployment note: Take care to use a dedicated Redis cache rather than pointing this at your existing Redis server. It won't cope well with mixed usage patterns and it won't expire cache entries by default.

Redis cache server setup guide: https://redis.io/topics/lru-cache

@jeremy jeremy force-pushed the jeremy:redis-cache-store branch 7 times, most recently Nov 13, 2017
.travis.yml Outdated
@@ -41,7 +42,7 @@ before_script:
# Decodes to e.g. `export VARIABLE=VALUE`
- $(base64 --decode <<< "ZXhwb3J0IFNBVUNFX0FDQ0VTU19LRVk9YTAzNTM0M2YtZTkyMi00MGIzLWFhM2MtMDZiM2VhNjM1YzQ4")
- $(base64 --decode <<< "ZXhwb3J0IFNBVUNFX1VTRVJOQU1FPXJ1YnlvbnJhaWxz")
- redis-server --bind 127.0.0.1 --port 6379 --requirepass 'password' --daemonize yes
- redis-server --bind 127.0.0.1 --port 6380 --requirepass 'password' --daemonize yes

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Separate Redis for Action Cable tests which specifically want to work against a non-default config.

Use the default redis-server service for Active Support cache store tests.

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

Maybe we should have the test suite spin this up & down directly? Having to set a password on my local redis to get the tests to pass was annoying, but making everyone manually run a second instance seems even worse.

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Yeah. I'd rather change the Action Cable tests separately. The point of this was to demonstrate that AC would accept user credentials for Redis. A mocked test should be sufficient. Then we can use a default Redis config for all tests.

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Done in #31136

"#{namespace}:#{key}"
else
key
end
end

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Separated this method from normalize_key so they may be overridden and called independently by subclasses.

The Redis cache store hashes cache keys larger than 1kB and it namespaces deleted_matched globs, which aren't keys to be normalized.

This comment has been minimized.

@rafaelfranca

rafaelfranca Nov 13, 2017
Member

This method was removed from public API in Rails 5.0. #22215 for context.

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

I'll call it namespace_key instead, matching verb tense with normalize_key. (It's a different method than the old namespaced_key which expanded, sanitized, namespaced, etc.)

This comment has been minimized.

activesupport/lib/active_support/cache/redis_cache_store.rb Outdated

begin
require "redis"
require "redis/distributed"

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Always require redis/distributed, even if we use a single Redis, so we can reference Redis::Distributed::CannotDistribute below.

begin
require "redis/connection/hiredis"
rescue LoadError
end

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Maybe it's dubious. Maybe it's good practice. Maybe it's …

activesupport/lib/active_support/cache/redis_cache_store.rb Outdated
end

require "digest/md5"
require "active_support/core_ext/marshal"

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

For large-key truncation and for entry marshaling.

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

md5 can be problematic; while it is slower, I think we should probably just use sha2.

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Agree. I used MD5 to match MemCacheStore. I'd like to flip them both.

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Switched to SHA2. MemCacheStore can catch up later.

activesupport/lib/active_support/cache/redis_cache_store.rb Outdated
else
super
end
end

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Same story here, assuming #mset-capable and falling back if not.

else
key
end
end

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Worth hoisting this out too.


def failsafe(method, returning: nil)
yield
rescue ::Redis::BaseConnectionError => e

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

May need to expand the set of failsafe exceptions, and allow Rails users to do it.

end
rescue => failsafe
warn "RedisCacheStore ignored exception in handle_exception: #{failsafe.class}: #{failsafe.message}\n #{failsafe.backtrace.join("\n ")}"
end

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Exceptions are swallowed by default. Apps provide an error_handler to report on them.


test "write failure returns nil" do
end
end

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

TBD

@jeremy jeremy force-pushed the jeremy:redis-cache-store branch Nov 13, 2017

Deployment note: Take care to use a *dedicated Redis cache* rather
than pointing this at your existing Redis server. It won't cope well
with mixed usage patterns and it won't expire cache entries by default.

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

Is this advice consistent with it defaulting to redis://localhost:6379/0? 🤔

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

I initially set this up without a default for that reason, but it felt needlessly fussy for dev/test. I think clearly, repeatedly directing users to deployment guidance is the important ingredient.

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

Maybe we should default to some non-nil expiry, and then people heeding the "how to use this at its best" guidance can turn that off at the same time they point to a dedicated instance?

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

I like that direction since it require an informed choice of Redis usage mode.

Thing is, by default, Redis doesn't expire keys when it hits max mem—it returns errors. For expiry alone to work, we'd need to use volatile-lru/ttl max-mem policy instead of the default noeviction.

That suggests that we configure a volatile/allkeys policy on the cache store as well. Then we can inspect maxmemory-policy to satisfy ourselves that the server is on board with the user's intent.

activesupport/lib/active_support/cache/redis_cache_store.rb Outdated
end

require "digest/md5"
require "active_support/core_ext/marshal"

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

md5 can be problematic; while it is slower, I think we should probably just use sha2.

activesupport/lib/active_support/cache/redis_cache_store.rb Outdated
else
super
end
end

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

Is it worth checking this when we connect? I remember a rescue-covered block used to be fairly expensive to enter, regardless of whether the exception ever occurred... but maybe my memory's out of date?

raise ArgumentError, "Only Redis glob strings are supported: #{matcher.inspect}"
end
end
end

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

Seems fine, but that would imply this method isn't "Cache Store API implementation".

activesupport/lib/active_support/cache/redis_cache_store.rb Outdated
# Removes expired entries. Not supported.
def cleanup(options = nil)
super
end

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

Is expiry not supported at all? Or is expiry not used by default, and then when it is, delegated to redis? (Meaning this method would be a no-op, but would be "working")

activesupport/lib/active_support/cache/redis_cache_store.rb Outdated
# For compat with the wexpected options hash argument.
def write_entry(key, entry, options = nil)
write_entry_kwargs key, entry, **options
end

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

Don't these have identical arities? 😕

# Compression is enabled by default with a 1kB threshold, so cached
# values larger than 1kB are automatically compressed. Disable by
# passing `cache: false` or change the threshold by passing
# `compress_threshold: 4.kilobytes`.

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

This does sound like a good idea, but I wonder whether it's up to the redis store to have that opinion. Seems more like it should be the global default (and then overridden for the in-memory store)?

.travis.yml Outdated
@@ -41,7 +42,7 @@ before_script:
# Decodes to e.g. `export VARIABLE=VALUE`
- $(base64 --decode <<< "ZXhwb3J0IFNBVUNFX0FDQ0VTU19LRVk9YTAzNTM0M2YtZTkyMi00MGIzLWFhM2MtMDZiM2VhNjM1YzQ4")
- $(base64 --decode <<< "ZXhwb3J0IFNBVUNFX1VTRVJOQU1FPXJ1YnlvbnJhaWxz")
- redis-server --bind 127.0.0.1 --port 6379 --requirepass 'password' --daemonize yes
- redis-server --bind 127.0.0.1 --port 6380 --requirepass 'password' --daemonize yes

This comment has been minimized.

@matthewd

matthewd Nov 13, 2017
Member

Maybe we should have the test suite spin this up & down directly? Having to set a password on my local redis to get the tests to pass was annoying, but making everyone manually run a second instance seems even worse.

@jeremy jeremy force-pushed the jeremy:redis-cache-store branch 3 times, most recently Nov 13, 2017
@jeremy jeremy force-pushed the jeremy:redis-cache-store branch 2 times, most recently Nov 13, 2017
"#{namespace}:#{key}"
else
key
end
end

This comment has been minimized.

@rafaelfranca

rafaelfranca Nov 13, 2017
Member

This method was removed from public API in Rails 5.0. #22215 for context.

activesupport/lib/active_support/cache/redis_cache_store.rb Outdated
require "redis"
require "redis/distributed"
rescue LoadError
warn "The Redis cache store requires the redis gem. Please add it to your Gemfile."

This comment has been minimized.

@rafaelfranca

rafaelfranca Nov 13, 2017
Member

Should we be doing gem 'redis in this file so we assert the minimum version requirement?

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Yes. I haven't carefully reviewed the oldest supported version yet. Will probably be Redis 3.3.0 with support for connect/read/write_timeout.

This comment has been minimized.

@jeremy

jeremy Nov 13, 2017
Author Member

Actually, requiring latest redis-rb (4.0.1+) is appropriate for a new feature like this. Then sharded cache read_multi will just work without needing additional warnings.

@jeremy jeremy force-pushed the jeremy:redis-cache-store branch Nov 13, 2017
"#{namespace}:#{key}"
else
key
end
end

This comment has been minimized.

* Supports vanilla Redis, hiredis, and Redis::Distributed.
* Supports Memcached-like sharding across Redises with Redis::Distributed.
* Fault tolerant. If the Redis server is unavailable, no exceptions are
  raised. Cache fetches are treated as misses and writes are dropped.
* Local cache. Hot in-memory primary cache within block/middleware scope.
* `read_/write_multi` support for Redis mget/mset. Use Redis::Distributed
  4.0.1+ for distributed mget support.
* `delete_matched` support for Redis KEYS globs.
@jeremy jeremy force-pushed the jeremy:redis-cache-store branch to 31f51b3 Nov 14, 2017
@jeremy jeremy merged commit 9f8ec35 into rails:master Nov 14, 2017
2 checks passed
2 checks passed
codeclimate All good!
Details
continuous-integration/travis-ci/pr The Travis CI build passed
Details
@jeremy jeremy deleted the jeremy:redis-cache-store branch Nov 14, 2017
@jeremy jeremy added this to the 5.2.0 milestone Nov 14, 2017
@jeremy
Copy link
Member Author

@jeremy jeremy commented Nov 14, 2017

Thanks for review, @matthewd @rafaelfranca 🖖🏼

@sorentwo
Copy link
Contributor

@sorentwo sorentwo commented Dec 8, 2017

I'm a bit disappointed to see this from-scratch implementation considering all the effort that was put into https://github.com/sorentwo/readthis. It already supports every feature this implements, aside from distributed redis and local cache support.

Porting Readthis would have been a great way to get something that is well documented, tested, and has been used in production for years into Rails.

@jkburges
Copy link

@jkburges jkburges commented Jan 10, 2018

I have a question about the syntax of the configuration, raised at https://stackoverflow.com/questions/48177606/ruby-syntax-error-unexpected-tlabel

I didn't think it was appropriate to discuss the question directly here, but hopefully ok to link to it, in case someone here can shed some light on it :-)

@steakknife
Copy link
Contributor

@steakknife steakknife commented May 4, 2018

@jkburges No biggie: it's not a function but an assignment. Surround the keys with braces. Should work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked issues

Successfully merging this pull request may close these issues.

None yet

6 participants
You can’t perform that action at this time.