doc/development/caching.md
This document describes the various caching strategies in use at GitLab, how to implement them effectively, and various gotchas. This material was extracted from the excellent Caching Workshop.
A faster store for data, which is:
The goal for every web page should be to return in under 100 ms:
Despite downsides to Redis caching, you should still feel free to make good use of the caching setup inside the GitLab application and on GitLab.com. Our forecasting for cache utilization indicates we have plenty of headroom.
Is the cache being added "worthy"? This can be hard to measure, but you can consider:
?performance_bar=flamegraph to the URL to help find
the methods where time is being spent.jq:
tail -f log/development_json.log | jq ".duration_s"tail -f log/api_json.log | jq ".duration_s"development.log:
tail -f log/development.log | grep "cache hits"tail -f log/development.log | grep "Rendered "binding.pry to poke about in live requests. This requires a
foreground web process.?performance_bar=flamegraph to the pagepublic setting.@article ||= Article.find(params[:id])strong_memoize_attr :method_nameGitlab::SafeRequestStore.fetchThis is well-documentation in the Rails guides
cache in views, which is almost an alias for:Rails.cache.fetch, which you can use everywhere.cache includes a "template tree digest" which changes when you modify your view files.expires_inThis sets the Time To Live (TTL) for the cache entry, and is the single most useful (and most commonly used) cache option. This is supported in most Rails caching helpers.
The TTL, if not set with expires_in,
defaults to 8 hours.
Consider using an 8 hour TTL for general caching, as this matches a workday and would mean that a user would generally only have one cache-miss per day for the same content.
When writing large amounts of data, consider using a shorter expiry to decrease its impact on the memory usage.
race_condition_ttlThis option prevents multiple uncached hits for a key at the same time. The first process that finds the key expired bumps the TTL by this amount, and it then sets the new cache value.
Used when a cache key is under very heavy load to prevent multiple simultaneous writes, but should be set to a low value, such as 10 seconds.
Rails.cache uses Redis as the store.
GitLab instances, like GitLab.com, can configure Redis for key eviction.
See the Redis development guide.
Use conditional GET caching when the entire response is cacheable:
updated_at value for the etag.This is no longer very commonly used in the Rails world:
cache_action.All the time!
cached: true on them.StrongMemoize.Gitlab::Cache.fetch_once works.Gitlab::SafeRequestStore.fetch(key) { ... } returns the same value for a key for the
duration of one request, Sidekiq job, or ActionCable work unit.
The store is a plain in-memory hash on Thread.current, so it is isolated per thread and
holds no serialized data.
Middleware clears it at the end of each unit of work.
Outside an active request, the store falls back to executing the block on every call.
Prefer Gitlab::SafeRequestStore over RequestStore to skip manual RequestStore.active?
checks.
The most common use is to cache a finder or query result that a collection of records resolves identically, so the finder runs once per request instead of once per record.
The status widget uses this to resolve a work item lifecycle. The key combines the root namespace ID and the work item type ID, and the block wraps the finder:
def find_lifecycle
root_namespace_id = namespace&.traversal_ids&.first
return work_item_type.system_defined_lifecycle unless root_namespace_id
cache_key = "work_item_custom_lifecycle_#{root_namespace_id}_#{work_item_type_id}"
custom_lifecycle = Gitlab::SafeRequestStore.fetch(cache_key) do
work_item_type.custom_lifecycle_for(root_namespace_id)
end
custom_lifecycle || work_item_type.system_defined_lifecycle
end
For the full example, see
WorkItems::HasStatus#find_lifecycle.
This works because records across the namespace subtree share the cached value. Two records of the same type in the same root namespace resolve to the same value, so the root namespace ID and type ID make a correct key.
namespace.traversal_ids.first, not namespace.root_ancestor.
The traversal_ids column is already loaded on the record, so it avoids the extra query that
root_ancestor triggers for each record, which would defeat the purpose of the cache.BatchLoader::GraphQL in GraphQL resolvers, or Gitlab::SafeRequestLoader on the REST API.When one object calls the cached method many times in a single request, wrap the store lookup
in StrongMemoize. The two layers do different work:
RequestStore.active? check and the key lookup that Gitlab::SafeRequestStore.fetch runs
every time.The combination is a micro-optimization for hot paths, not the default.
For an example, see
EE::WorkItems::TypesFramework::Provider#indexed_cache.
Gitlab::Cache::JsonCache
and Gitlab::SafeRequestStore, for example, can lead to stale data issues
where the cache data doesn't have the appropriate value for the new attribute
(see this past incident).Rails uses this automatically for identical queries in a request, so no action is needed for that use case.
identity_cache has a different purpose: caching queries
across multiple requests.Article.find(params[:id]).If you've exhausted other options, and must cache something that's really awkward, it's time to look at a custom solution:
RepositorySetCache, RepositoryHashCache and AvatarCache.merged_branch_names,
using RepositoryHashCache.In short: the oldest stuff is replaced with new stuff:
allkeys-lru, which is functionally similar to Memcached.UNLINK instead of DEL now, which allows Redis to
reclaim memory in its own time, rather than immediately.
cache_key_with_version and cache_key.
The first one is used by default in version 5.2 and later, and is the standard behavior from before;
it includes the updated_at timestamp in the key.Example found in the application.log:
cache(@project, :tag_list)
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29/projects/16-2021031614242546945
2/tag_list
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29updated_at values
projects/16-20210316142425469452tag_listupdated_at field to changeGrape::Entity makes effective caching extremely difficult in the API layer. More on this later.break or return inside the fragment cache helper in views - it never writes a cache entry.nil and swapping them around.{ project: nil } instead.#cache_key on members of an array to find the keys, but it doesn't call it on values of hashes.