blob: 21177505db92e7a05b4b8b35c207f686eb2df4d6 [file] [log] [blame]
Prashanth B2d8047e2014-04-27 18:54:47 -07001# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5
6"""Cache module for rdb requests/host objects.
7
8This module supplies the following api:
9 1. A cache backend.
10 2. A cache manager for the backend.
11 3. A memoize decorator to encapsulate caching logic.
12
13This cache manager functions as a lookaside buffer for host requests.
14Its correctness is contingent on the following conditions:
151. The concurrency of the rdb remains 0.
162. Clients of the cache don't trust the leased bit on the cached object.
173. The cache is created at the start of a single batched request,
18 populated during the request, and completely discarded at the end.
19
20Rather than caching individual hosts, the cache manager maintains
21'cache lines'. A cache line is defined as a key: value pair, where
22the key is as returned by get_key, and the value is a list of RDBHosts
23that match the key. The following limitations are placed on cache lines:
241. A new line can only contain unleased hosts.
252. A key can only be set once, with a single line, before removal.
263. Every 'get' deletes the entire line.
27
28Consider the following examples:
29Normal case: 3 grouped requests, all with the same deps/acls, but different
30priorities/parent_job_ids. The requests (X+Y+Z) > matching hosts (K):
31 (request1, count=X)- hits the database, takes X hosts, caches (K-X)
32 (request2, count=Y) - hits the cache and is fully satisfied, caches (K-(X+Y))
33 (request3, count=Z) - hits the cache, needs to acquire (X+Y+Z)-K next tick]:
34
35 Host Count | RDB | Cache
36------------------------------------------------------------------
37X: | request1 | {}
38K: | find_hosts(deps, acls) |
39X: | leased_hosts |
40K-X: | ---------------------------> | {key: [K-X hosts]}
41Y<K-X: | request2 <---[K-X hosts]---- | {}
42Y: | leased_hosts |
43K-(X+Y): | ---------------------------> | {key: [K-(X+Y) hosts]}
44Z>K-(X+Y): | request3 <-[K-(X+Y) hosts]-- | {}
45Z-(K-(X+Y)):| leased_hosts |
46
47Since hosts are only released by the scheduler there is no way the
48third request could have been satisfied completely even if we had checked
49the database real-time.
50
51Stale cache entries: 3 grouped requests that don't have the same deps/acls.
52P(1,2,3) are priorities, with P3 being the highest:
53 (request1(deps=[a,b], P3), Count=X) - Caches hosts
54 (request2(deps=[a], P2), Count=Y) - hits the database
55 (request3(deps=[a,b], P1)], Count=Z) - Tries to use cached hosts but fails
56
57 Host Count | RDB | Cache
58------------------------------------------------------------------
59X: | request1(deps=[a,b]) | {}
60K: | find_hosts(deps=[a,b]) |
61X: | leased_hosts |
62K-X: | ---------------------------> | {deps=[a,b]: [(K-X) hosts]}
63Y<K-X: | request2(deps=[a]) | {}
64K-X: | find_hosts(deps=[a]) |
65Y: | leased_hosts |
66K-(X+Y): | ---------------------------> | {deps=[a]: [(K-(X+Y)) hosts],
67 | | | overlap |
68 | | deps=[a, b], [(K-X) hosts]}
69Z: | request3(deps=[a,b])<-[K-X]--| {deps=[a]: [K-(X+Y) hosts]}
70Z-(K-(X+Y)):| leased_hosts | {deps=[a]: [N-Y hosts]}
71
72Note that in the last case, even though the cache returns hosts that
73have already been assigned to request2, request3 cannot use them. This is
74acceptable because the number of hosts we lease per tick is << the number
75of requests, so it's faster to check leased bits real time than query for hosts.
76"""
77
78
79import abc
80import collections
81import logging
82
83import common
84from autotest_lib.client.common_lib.global_config import global_config
85from autotest_lib.scheduler import rdb_utils
Prashanth B2d8047e2014-04-27 18:54:47 -070086
xixuan7224dcb2016-11-22 17:11:41 -080087from chromite.lib import metrics
88
Prashanth B2d8047e2014-04-27 18:54:47 -070089MEMOIZE_KEY = 'memoized_hosts'
90
91def memoize_hosts(func):
92 """Decorator used to memoize through the cache manager.
93
94 @param func: The function/method to decorate.
95 Before calling this function we check the cache for values matching
96 its request argument, and anything returned by the function is cached
97 cached under the same request.
98 """
99 def cache(self, request, count, **kwargs):
100 """Caching function for the memoize decorator.
101
102 @param request: The rdb request, as defined in rdb_requests.
103 @param count: The count of elements needed to satisfy the request.
104 @param kwargs:
105 Named args for the memoized function. This map should not contain
106 the key MEMOIZED_KEY, as this is reserved for the passing of
107 the cached/memoized hosts to the function itself.
108 """
109 cache_key = self.cache.get_key(request.deps, request.acls)
110 try:
111 kwargs[MEMOIZE_KEY] = self.cache.get_line(cache_key)
112 except rdb_utils.CacheMiss:
113 pass
114 hosts = func(self, request, count, **kwargs)
115 self.cache.set_line(cache_key, hosts)
116 return hosts
117 return cache
118
119
120class CacheBackend(object):
121 """Base class for a cache backend."""
122 __metaclass__ = abc.ABCMeta
123
124 def set(self, key, value):
125 """Set a key.
126
127 @param key: The key to set.
128 @param value: The value to cache.
129 """
130 pass
131
132
133 def get(self, key):
134 """Get the value stored under a key.
135
136 @param key: The key to retrieve the value for.
137 @return: The value stored under the key.
138 @raises KeyError: If the key isn't present in the cache.
139 """
140 pass
141
142
143 def delete(self, key):
144 """Delete the key, value pair from the cache.
145
146 @param key: The key used to find the key, value pair to delete.
147 @raises KeyError: If the key isn't already in the cache.
148 """
149 pass
150
151
152 def has_key(self, key):
153 """Check if the key exists in the cache.
154
155 @param key: The key to check.
156 @return: True if the key is in the cache.
157 """
158 return False
159
160
161class DummyCacheBackend(CacheBackend):
162 """A dummy cache backend.
163
164 This cache will claim to have no keys. Every get is a cache miss.
165 """
166
167 def get(self, key):
168 raise KeyError
169
170
171class InMemoryCacheBackend(CacheBackend):
172 """In memory cache backend.
173
174 Uses a simple dictionary to store key, value pairs.
175 """
176 def __init__(self):
177 self._cache = {}
178
179 def get(self, key):
180 return self._cache[key]
181
182 def set(self, key, value):
183 self._cache[key] = value
184
185 def delete(self, key):
186 self._cache.pop(key)
187
188 def has_key(self, key):
189 return key in self._cache
190
191# TODO: Implement a MemecacheBackend, invalidate when unleasing a host, refactor
192# the AcquireHostRequest to contain a core of (deps, acls) that we can use as
193# the key for population and invalidation. The caching manager is still valid,
194# regardless of the backend.
195
196class RDBHostCacheManager(object):
197 """RDB Cache manager."""
198
199 key = collections.namedtuple('key', ['deps', 'acls'])
200 use_cache = global_config.get_config_value(
201 'RDB', 'use_cache', type=bool, default=True)
202
203 def __init__(self):
204 self._cache_backend = (InMemoryCacheBackend()
205 if self.use_cache else DummyCacheBackend())
206 self.hits = 0
207 self.misses = 0
208 self.stale_entries = []
209
210
211 def mean_staleness(self):
212 """Compute the average stale entries per line.
213
214 @return: A floating point representing the mean staleness.
215 """
216 return (reduce(lambda x, y: float(x+y), self.stale_entries)/
217 len(self.stale_entries)) if self.stale_entries else 0
218
219
220 def hit_ratio(self):
221 """Compute the hit ratio of this cache.
222
223 @return: A floating point percentage of the hit ratio.
224 """
225 if not self.hits and not self.misses:
226 return 0
227 requests = float(self.hits + self.misses)
228 return (self.hits/requests) * 100
229
230
231 def record_stats(self):
232 """Record stats about the cache managed by this instance."""
233 hit_ratio = self.hit_ratio()
234 staleness = self.mean_staleness()
235 logging.debug('Cache stats: hit ratio: %.2f%%, '
236 'avg staleness per line: %.2f%%.', hit_ratio, staleness)
xixuan7224dcb2016-11-22 17:11:41 -0800237 metrics.Float('chromeos/autotest/scheduler/rdb/cache/hit_ratio').set(
238 hit_ratio)
239 metrics.Float(
240 'chromeos/autotest/scheduler/rdb/cache/mean_staleness').set(
241 staleness)
Prashanth B2d8047e2014-04-27 18:54:47 -0700242
243
244 @classmethod
245 def get_key(cls, deps, acls):
246 """Return a key for the given deps, acls.
247
248 @param deps: A list of deps, as taken by the AcquireHostRequest.
249 @param acls: A list of acls, as taken by the AcquireHostRequest.
250 @return: A cache key for the given deps/acls.
251 """
252 # All requests with the same deps, acls should hit the same cache line.
253 # TODO: Do something smarter with acls, only one needs to match.
254 return cls.key(deps=frozenset(deps), acls=frozenset(acls))
255
256
257 def get_line(self, key):
258 """Clear and return the cache line matching the key.
259
260 @param key: The key the desired cache_line is stored under.
261 @return: A list of rdb hosts matching the key, or None.
262
263 @raises rdb_utils.CacheMiss: If the key isn't in the cache.
264 """
265 try:
266 cache_line = self._cache_backend.get(key)
267 except KeyError:
268 self.misses += 1
269 raise rdb_utils.CacheMiss('Key %s not in cache' % (key,))
270 self.hits += 1
271 self._cache_backend.delete(key)
272 return list(cache_line)
273
274
275 def _check_line(self, line, key):
276 """Sanity check a cache line.
277
278 This method assumes that a cache line is made up of RDBHost objects,
279 and checks to see if they all match each other/the key passed in.
280 Checking is done in terms of host labels and acls, note that the hosts
281 in the line can have different deps/acls, as long as they all have the
282 deps required by the key, and at least one matching acl of the key.
283
284 @param line: The cache line value.
285 @param key: The key the line will be stored under.
286 @raises rdb_utils.RDBException:
287 If one of the hosts in the cache line is already leased.
288 The cache already has a different line under the given key.
289 The given key doesn't match the hosts in the line.
290 """
291 # Note that this doesn't mean that all hosts in the cache are unleased.
292 if any(host.leased for host in line):
293 raise rdb_utils.RDBException('Cannot cache leased hosts %s' % line)
294
295 # Confirm that the given line can be used to service the key by checking
296 # that all hosts have the deps mentioned in the key, and at least one
297 # matching acl.
298 h_keys = set([self.get_key(host.labels, host.acls) for host in line])
299 for h_key in h_keys:
300 if (not h_key.deps.issuperset(key.deps) or
301 not key.acls.intersection(h_key.acls)):
302 raise rdb_utils.RDBException('Given key: %s does not match key '
303 'computed from hosts in line: %s' % (key, h_keys))
304 if self._cache_backend.has_key(key):
305 raise rdb_utils.RDBException('Cannot override a cache line. It '
306 'must be cleared before setting. Key: %s, hosts %s' %
307 (key, line))
308
309
310 def set_line(self, key, hosts):
311 """Cache a list of similar hosts.
312
313 set_line will no-op if:
314 The hosts aren't all unleased.
315 The hosts don't have deps/acls matching the key.
316 A cache line under the same key already exists.
317 The first 2 cases will lead to a cache miss in the corresponding get.
318
319 @param hosts: A list of unleased hosts with the same deps/acls.
320 @raises RDBException: If hosts is None, since None is reserved for
321 key expiration.
322 """
323 if hosts is None:
324 raise rdb_utils.RDBException('Cannot set None in the cache.')
325
326 # An empty list means no hosts matching the request are available.
327 # This can happen if a previous request leased all matching hosts.
328 if not hosts or not self.use_cache:
329 self._cache_backend.set(key, [])
330 return
331 try:
332 self._check_line(hosts, key)
333 except rdb_utils.RDBException as e:
334 logging.error(e)
335 else:
336 self._cache_backend.set(key, set(hosts))