1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
|
# frozen_string_literal: true
module Gitlab
module Cache
module Helpers
# @return [ActiveSupport::Duration]
DEFAULT_EXPIRY = 1.day
# @return [ActiveSupport::Cache::Store]
def cache
Rails.cache
end
def render_cached(obj_or_collection, with:, cache_context: -> (_) { current_user&.cache_key }, expires_in: Gitlab::Cache::Helpers::DEFAULT_EXPIRY, **presenter_args)
json =
if obj_or_collection.is_a?(Enumerable)
cached_collection(
obj_or_collection,
presenter: with,
presenter_args: presenter_args,
context: cache_context,
expires_in: expires_in
)
else
cached_object(
obj_or_collection,
presenter: with,
presenter_args: presenter_args,
context: cache_context,
expires_in: expires_in
)
end
render Gitlab::Json::PrecompiledJson.new(json)
end
private
# Optionally uses a `Proc` to add context to a cache key
#
# @param object [Object] must respond to #cache_key
# @param context [Proc] a proc that will be called with the object as an argument, and which should return a
# string or array of strings to be combined into the cache key
# @return [String]
def contextual_cache_key(presenter, object, context)
return object.cache_key if context.nil?
[presenter.class.name, object.cache_key, context.call(object)].flatten.join(":")
end
# Used for fetching or rendering a single object
#
# @param object [Object] the object to render
# @param presenter [Grape::Entity]
# @param presenter_args [Hash] keyword arguments to be passed to the entity
# @param context [Proc]
# @param expires_in [ActiveSupport::Duration, Integer] an expiry time for the cache entry
# @return [String]
def cached_object(object, presenter:, presenter_args:, context:, expires_in:)
misses = 0
json = cache.fetch(contextual_cache_key(presenter, object, context), expires_in: expires_in) do
time_action(render_type: :object) do
misses += 1
Gitlab::Json.dump(presenter.represent(object, **presenter_args).as_json)
end
end
increment_cache_metric(render_type: :object, total_count: 1, miss_count: misses)
json
end
# Used for fetching or rendering multiple objects
#
# @param objects [Enumerable<Object>] the objects to render
# @param presenter [Grape::Entity]
# @param presenter_args [Hash] keyword arguments to be passed to the entity
# @param context [Proc]
# @param expires_in [ActiveSupport::Duration, Integer] an expiry time for the cache entry
# @return [Array<String>]
def cached_collection(collection, presenter:, presenter_args:, context:, expires_in:)
misses = 0
json = fetch_multi(presenter, collection, context: context, expires_in: expires_in) do |obj|
time_action(render_type: :collection) do
misses += 1
Gitlab::Json.dump(presenter.represent(obj, **presenter_args).as_json)
end
end
increment_cache_metric(render_type: :collection, total_count: collection.length, miss_count: misses)
json.values
end
# An adapted version of ActiveSupport::Cache::Store#fetch_multi.
#
# The original method only provides the missing key to the block,
# not the missing object, so we have to create a map of cache keys
# to the objects to allow us to pass the object to the missing value
# block.
#
# The result is that this is functionally identical to `#fetch`.
def fetch_multi(presenter, *objs, context:, **kwargs)
objs.flatten!
map = multi_key_map(presenter, objs, context: context)
# TODO: `contextual_cache_key` should be constructed based on the guideline https://docs.gitlab.com/ee/development/redis.html#multi-key-commands.
Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
cache.fetch_multi(*map.keys, **kwargs) do |key|
yield map[key]
end
end
end
# @param objects [Enumerable<Object>] objects which _must_ respond to `#cache_key`
# @param context [Proc] a proc that can be called to help generate each cache key
# @return [Hash]
def multi_key_map(presenter, objects, context:)
objects.index_by do |object|
contextual_cache_key(presenter, object, context)
end
end
def increment_cache_metric(render_type:, total_count:, miss_count:)
return unless caller_id
metric_name = :cached_object_operations_total
hit_count = total_count - miss_count
current_transaction&.increment(
metric_name,
hit_count,
{ caller_id: caller_id, render_type: render_type, cache_hit: true }
)
current_transaction&.increment(
metric_name,
miss_count,
{ caller_id: caller_id, render_type: render_type, cache_hit: false }
)
end
def time_action(render_type:, &block)
real_start = Gitlab::Metrics::System.monotonic_time
presented_object = yield
real_duration_histogram(render_type).observe({}, Gitlab::Metrics::System.monotonic_time - real_start)
presented_object
end
def real_duration_histogram(render_type)
Gitlab::Metrics.histogram(
:gitlab_presentable_object_cacheless_render_real_duration_seconds,
'Duration of generating presentable objects to be cached in real time',
{ caller_id: caller_id, render_type: render_type },
[0.1, 0.5, 1, 2]
)
end
def current_transaction
@current_transaction ||= ::Gitlab::Metrics::WebTransaction.current
end
def caller_id
@caller_id ||= Gitlab::ApplicationContext.current_context_attribute(:caller_id)
end
end
end
end
|