Rails cache
Contents |
Guide tells that page and action caching has been extracted to gems and currently we use fragment caching only.
By default caching in rails development environment is disabled. To enable you
need to touch tmp/caching-dev.txt
AND to restart the server. You can see in
config that you can also toggle with rails dev:cache
# config/environments/development.rb
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.cache_store = :memory_store
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{2.days.to_i}"
}
else
config.action_controller.perform_caching = false
config.cache_store = :null_store
end
So by default is not enabled in development so in rails console we see
Rails.cache
# @name="ActiveSupport::Cache::Strategy::LocalCache">
Rails.cache.class
=> ActiveSupport::Cache::NullStore
If we run rails dev:cache
than it uses memory (persists untill you exit the
console)
Rails.cache
=> #<ActiveSupport::Cache::MemoryStore entries=0, size=0, options={:compress=>false}>
Rails.cache.class
=> ActiveSupport::Cache::MemoryStore
# before it was `ActiveSupport::Cache::FileStore`
On production we should switch to use dalli+memcached
or redis
(you already
have redis when you use background jobs like Sidekiq).
Filestore will create files on tmp/cache/:rand/:rand/cache_key
so you can find
them ls -R tmp/cache/
. If cache_key is array, it is joined with /
.
On heroku heroku run bash
I do not see cache files ls tmp/cache
probably
because heroku is read only system (only buildpack can add stuff) so it is
better to use memcached.
Redis cache store
In Rails 5.2 you can use
# Gemfile
gem 'hiredis'
gem 'redis', require: ['redis', 'redis/connection/hiredis']
# config/application/environments/production.rb
# config/application/environments/development.rb
config.cache_store = :redis_cache_store
In console you can check keys and values
pp Rails.cache.redis.keys
Install memcached
For ubuntu you need to install memcached service. Note that memcached will automatically remove old cache files.
sudo apt-get install memcached
sudo service memcached status
For heroku you need addon memcachier
heroku addons:create memcachier:dev
This will create env variables which you can use in secrets
# sed -i config/secrets.yml -e '/^test:/i
# caching server
memcachier_servers: <%= ENV["MEMCACHIER_SERVERS"] %>
memcachier_username: <%= ENV["MEMCACHIER_USERNAME"] %>
memcachier_password: <%= ENV["MEMCACHIER_PASSWORD"] %>
'
Use gem dalli in your Gemfile
cat >> Gemfile << HERE_DOC
# https://guides.rubyonrails.org/caching_with_rails.html#activesupport-cache-memcachestore
# cache store is in mem_cache_store which uses dalli
gem 'dalli'
HERE_DOC
cat >> config/initializers/dalli.rb << HERE_DOC
Rails.application.configure do
# https://devcenter.heroku.com/articles/building-a-rails-3-application-with-memcache
# https://devcenter.heroku.com/articles/memcachier
config.cache_store = :mem_cache_store # this will use local memcached service
if secrets.memcachier_servers.present?
config.cache_store = :mem_cache_store,
secrets.memcachier_servers.split(','),
{
username: secrets.memcachier_username,
password: secrets.memcachier_password,
failover: true,
socket_timeout: 1.5,
socket_failure_delay: 0.2,
down_retry_delay: 60
}
end
end
HERE_DOC
Try with heroku run rails c
Rails.application.config.cache_store
Install memcached using rubber
Read about capistrano and rubber how to set up, so you can add memcached with
# this will copy deploy-memcached.rb and rubber-memcached.yml and role files
bundle exec rubber vulcanize memcached
Add role memcached
to Vagrantfile or your hosts.
Since vagrant machine do not have
image_type
which is used to determine max_mem, you need to define it
# config/rubber/rubber-memcached.yml
memcached_max_mem: 2048
memcached_max_mem_hash:
...
Also we need to enable listening on all required ports.
To use in rails you need to add
cat >> config/initializers/dalli.rb << HERE_DOC
Rails.application.configure do
dalli_config = Rails.root.join('config','dalli.rb')
if File.exists?(dalli_config)
require dalli_config; include ::Rubber::Dalli::Config
else
config.cache_store = :dalli_store # this will use local memcached service
end
end
HERE_DOC
Install in plain irb
require "rubygems"
require "dalli"
CACHE = Dalli::Client.new("127.0.0.1", { :namespace => 'my_project', :expires_in => 3600, :socket_timeout => 3, :compress => true }) # Options are self-explanatory
CACHE.set("key", "value", 180) # last option is expiry time in seconds
CACHE.get("key")
Manual check if it is working
You can check from bash if memcached service is running
netstat -ap | grep 11211
# tcp 0 0 localhost:11211 *:* LISTEN 17897/memcached
# udp 0 0 localhost:11211 *:* 17897/memcached
# if listening on all ip addresses is enabled than you should see
tcp 0 0 *:11211 *:* LISTEN 16065/memcached
udp 0 0 *:11211 *:* 16065/memcached
You should be able to connect
telnet localhost 11211
stats
# another command is
/usr/share/memcached/scripts/memcached-tool localhost:11211 stats
If you use vagrant, than config/rubber/role/memcached/dalli.rb
will use
rubber_instance.full_name
which is default.foo.com
but I’m not able to
connect using that domain name. So you need to change
config/rubber/role/memcached/memcached.conf
to add below comment # -l
127.0.0.1
add this option
# we want to listen on all IP addresses
-l 0.0.0.0
You can check in console if properly enabled
Rails.cache.class
And if check its stats
Rails.cache.stats
# {"127.0.0.1:11211"=>{"pid"=>"14592", "uptime"=>"8319", ...
# if service is not working you can expect something like
[DEBUG] Dalli::Server#connect mylocationsubdomain.my-domain.vagrant:11211
[WARNING] mylocationsubdomain.my-domain.vagrant:11211 failed (count: 0) Errno::ECONNREFUSED: Connection refused - connect(2) for "mylocationsubdomain.my-domain.vagrant" port 11211
We can ask ActiveSupport for that also
ActiveSupport::Cache.lookup_store(:mem_cache_store).stats
If cache is using filestore exception will be raised
Rails.cache.stats
NoMethodError: undefined method `stats' for #<ActiveSupport::Cache::FileStore:0x000000058a45d0>
You can see more info about stats
Fragment caching
Main usage is like this
<% cache item do %>
bla
<% end %>
Key will contain template path, item id, item updated_at and template digest so if you change anything, cache will be expired.
For index pages I use key to be string, that is maximum updated at (also template digest will invalidate cache).
<% cache ['todos', @tasks.maximum(:updated_at)] do %>
My todos list
<% end %>
Note that if you use translations on different subdomain than put default locale in the key also.
<% cache ['moves-markers', I18n.locale, latest_move_timestamp] do %>
Note that you should not use same keys on multiple places on the page. So if you have to use on same page, use different first string.
Note that cache key should include all items that happens to be inside if
statement, for example we show different content for different users. Also if we
should numbers that depends on current day, for example Todays registered: <%=
User.where('created_at > ?', Time.zone.at_begging_of_day)
we should include
Date in cache keys since it should be updated (event we do not touch any user)
<% cache ['tickets', can?(:view, company), company.tickets.maxium(:updated_at)] do %>
<% if can? :view, company %>
Hi admin, here is the secret link
<% else %>
<%= company.tickets.all.size %>
<% end %>
<% end %>
To use simple keys you can
<% cache "asd", skip_digest: true do %>
<% end %>
To find cache key you can use
old = Rails.cache.redis.keys
# make a request
new = Rails.cache.redis.keys
new - old
["views/asd"]
You can clear cache in rails console : Rails.cache.clear
, also
rake tmp:cache:clear
for rails clear cache
You can use conditional caching you can use cache_if
<% cache_if params[:cache], product do %>
<%= render product %>
<% end %>
If you show some data from nested resources, for example product comment, you need to add relation so any update will also trigger parent update, and invalidate parent cache.
# app/models/comment.rb
belongs_to :product, touch: true
Inside json jbuilder you need to call json.cache!
# app/views/users/index.json.jbuilder
json.cache! ['users', @users.maxium(:updated_at)] do
...
end
Low level caching
In console or in code you can cache put, get
Rails.cache.write 'my_key', 123
Rails.cache.read 'my_key'
Rails.cache.fetch 'my_key' do
123
end
fetch
will read from cache if exists or write to cache
For any ActiveRecord instance, you have method cache_key
which you can use
for example in fetch
block.
class Product < ApplicationRecord
def competing_price
Rails.cache.fetch("#{cache_key}/competing_price", expires_in: 12.hours) do
Competitor::API.find_price(id)
end
end
end
cache key
uses id
and updated_at
Task.new.cache_key
# => "tasks/new"
Task.last.cache_key
# => "tasks/2-20170511114115000000"
Note that sql
caching is
only inside one action. Usually rails do not perform sql query untill it is
needed. If you only read current_user
in before actions, that query will be
cached. But if you update current_user, than following current_user
is not
reading from cache (for example if you use current_user in view or in other
before actions).
You can use this snippets
Test
Enable caching in test with
# config/environments/test.rb
if Rails.root.join("tmp/caching-test.txt").exist?
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.cache_store = :memory_store
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{2.days.to_i}"
}
else
config.action_controller.perform_caching = false
config.cache_store = :null_store
end
you can use helpers
# test/a/rails_cache_helper.rb
# Add `include RailsCacheHelper` to test/test_helper.rb
#
# Credits https://kevinjalbert.com/testing-the-use-of-rails-caching/
module RailsCacheHelper
# Enable cache with config/environments/test.rb and
# touch tmp/caching-test.txt
def with_clean_caching
Rails.cache.clear
yield
ensure
Rails.cache.clear
end
def cache_has_value?(value)
cache_data.values.map(&:value).any?(value)
end
def key_for_cached_value(value)
cache_data.each_value do |key, entry|
return key if entry&.value == value
end
end
private
def cache_data
Rails.cache.instance_variable_get(:@data)
end
end
and test for example