1# 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"""Rdb server module. 6""" 7 8import logging 9 10import common 11 12from django.core import exceptions as django_exceptions 13from django.db.models import fields 14from django.db.models import Q 15from autotest_lib.client.common_lib.cros.graphite import autotest_stats 16from autotest_lib.frontend.afe import models 17from autotest_lib.scheduler import rdb_cache_manager 18from autotest_lib.scheduler import rdb_hosts 19from autotest_lib.scheduler import rdb_requests 20from autotest_lib.scheduler import rdb_utils 21from autotest_lib.server import utils 22 23 24_timer = autotest_stats.Timer(rdb_utils.RDB_STATS_KEY) 25_is_master = not utils.is_shard() 26 27 28# Qeury managers: Provide a layer of abstraction over the database by 29# encapsulating common query patterns used by the rdb. 30class BaseHostQueryManager(object): 31 """Base manager for host queries on all hosts. 32 """ 33 34 host_objects = models.Host.objects 35 36 37 def update_hosts(self, host_ids, **kwargs): 38 """Update fields on a hosts. 39 40 @param host_ids: A list of ids of hosts to update. 41 @param kwargs: A key value dictionary corresponding to column, value 42 in the host database. 43 """ 44 self.host_objects.filter(id__in=host_ids).update(**kwargs) 45 46 47 @rdb_hosts.return_rdb_host 48 def get_hosts(self, ids): 49 """Get host objects for the given ids. 50 51 @param ids: The ids for which we need host objects. 52 53 @returns: A list of RDBServerHostWrapper objects, ordered by host_id. 54 """ 55 return self.host_objects.filter(id__in=ids).order_by('id') 56 57 58 @rdb_hosts.return_rdb_host 59 def find_hosts(self, deps, acls): 60 """Finds valid hosts matching deps, acls. 61 62 @param deps: A list of dependencies to match. 63 @param acls: A list of acls, at least one of which must coincide with 64 an acl group the chosen host is in. 65 66 @return: A set of matching hosts available. 67 """ 68 hosts_available = self.host_objects.filter(invalid=0) 69 queries = [Q(labels__id=dep) for dep in deps] 70 queries += [Q(aclgroup__id__in=acls)] 71 for query in queries: 72 hosts_available = hosts_available.filter(query) 73 return set(hosts_available) 74 75 76class AvailableHostQueryManager(BaseHostQueryManager): 77 """Query manager for requests on un-leased, un-locked hosts. 78 """ 79 80 host_objects = models.Host.leased_objects 81 82 83# Request Handlers: Used in conjunction with requests in rdb_utils, these 84# handlers acquire hosts for a request and record the acquisition in 85# an response_map dictionary keyed on the request itself, with the host/hosts 86# as values. 87class BaseHostRequestHandler(object): 88 """Handler for requests related to hosts, leased or unleased. 89 90 This class is only capable of blindly returning host information. 91 """ 92 93 def __init__(self): 94 self.host_query_manager = BaseHostQueryManager() 95 self.response_map = {} 96 97 98 def update_response_map(self, request, response, append=False): 99 """Record a response for a request. 100 101 The response_map only contains requests that were either satisfied, or 102 that ran into an exception. Often this translates to reserving hosts 103 against a request. If the rdb hit an exception processing a request, the 104 exception gets recorded in the map for the client to reraise. 105 106 @param response: A response for the request. 107 @param request: The request that has reserved these hosts. 108 @param append: Boolean, whether to append new hosts in 109 |response| for existing request. 110 Will not append if existing response is 111 a list of exceptions. 112 113 @raises RDBException: If an empty values is added to the map. 114 """ 115 if not response: 116 raise rdb_utils.RDBException('response_map dict can only contain ' 117 'valid responses. Request %s, response %s is invalid.' % 118 (request, response)) 119 exist_response = self.response_map.setdefault(request, []) 120 if exist_response and not append: 121 raise rdb_utils.RDBException('Request %s already has response %s ' 122 'the rdb cannot return multiple ' 123 'responses for the same request.' % 124 (request, response)) 125 if exist_response and append and not isinstance( 126 exist_response[0], rdb_hosts.RDBHost): 127 # Do not append if existing response contains exception. 128 return 129 exist_response.extend(response) 130 131 132 def _check_response_map(self): 133 """Verify that we never give the same host to different requests. 134 135 @raises RDBException: If the same host is assigned to multiple requests. 136 """ 137 unique_hosts = set([]) 138 for request, response in self.response_map.iteritems(): 139 # Each value in the response map can only either be a list of 140 # RDBHosts or a list of RDBExceptions, not a mix of both. 141 if isinstance(response[0], rdb_hosts.RDBHost): 142 if any([host in unique_hosts for host in response]): 143 raise rdb_utils.RDBException( 144 'Assigning the same host to multiple requests. New ' 145 'hosts %s, request %s, response_map: %s' % 146 (response, request, self.response_map)) 147 else: 148 unique_hosts = unique_hosts.union(response) 149 150 151 def _record_exceptions(self, request, exceptions): 152 """Record a list of exceptions for a request. 153 154 @param request: The request for which the exceptions were hit. 155 @param exceptions: The exceptions hit while processing the request. 156 """ 157 rdb_exceptions = [rdb_utils.RDBException(ex) for ex in exceptions] 158 self.update_response_map(request, rdb_exceptions) 159 160 161 def get_response(self): 162 """Convert all RDBServerHostWrapper objects to host info dictionaries. 163 164 @return: A dictionary mapping requests to a list of matching host_infos. 165 166 @raises RDBException: If the same host is assigned to multiple requests. 167 """ 168 self._check_response_map() 169 for request, response in self.response_map.iteritems(): 170 self.response_map[request] = [reply.wire_format() 171 for reply in response] 172 return self.response_map 173 174 175 def update_hosts(self, update_requests): 176 """Updates host tables with a payload. 177 178 @param update_requests: A list of update requests, as defined in 179 rdb_requests.UpdateHostRequest. 180 """ 181 # Last payload for a host_id wins in the case of conflicting requests. 182 unique_host_requests = {} 183 for request in update_requests: 184 if unique_host_requests.get(request.host_id): 185 unique_host_requests[request.host_id].update(request.payload) 186 else: 187 unique_host_requests[request.host_id] = request.payload 188 189 # Batch similar payloads so we can do them in one table scan. 190 similar_requests = {} 191 for host_id, payload in unique_host_requests.iteritems(): 192 similar_requests.setdefault(payload, []).append(host_id) 193 194 # If fields of the update don't match columns in the database, 195 # record the exception in the response map. This also means later 196 # updates will get applied even if previous updates fail. 197 for payload, hosts in similar_requests.iteritems(): 198 try: 199 response = self.host_query_manager.update_hosts(hosts, **payload) 200 except (django_exceptions.FieldError, 201 fields.FieldDoesNotExist, ValueError) as e: 202 for host in hosts: 203 # Since update requests have a consistent hash this will map 204 # to the same key as the original request. 205 request = rdb_requests.UpdateHostRequest( 206 host_id=host, payload=payload).get_request() 207 self._record_exceptions(request, [e]) 208 209 210 def batch_get_hosts(self, host_requests): 211 """Get hosts matching the requests. 212 213 This method does not acquire the hosts, i.e it reserves hosts against 214 requests leaving their leased state untouched. 215 216 @param host_requests: A list of requests, as defined in 217 rdb_utils.BaseHostRequest. 218 """ 219 host_ids = set([request.host_id for request in host_requests]) 220 host_map = {} 221 222 # This list will not contain available hosts if executed using 223 # an AvailableHostQueryManager. 224 for host in self.host_query_manager.get_hosts(host_ids): 225 host_map[host.id] = host 226 for request in host_requests: 227 if request.host_id in host_map: 228 self.update_response_map(request, [host_map[request.host_id]]) 229 else: 230 logging.warning('rdb could not get host for request: %s, it ' 231 'is already leased or locked', request) 232 233 234class AvailableHostRequestHandler(BaseHostRequestHandler): 235 """Handler for requests related to available (unleased and unlocked) hosts. 236 237 This class is capable of acquiring or validating hosts for requests. 238 """ 239 240 241 def __init__(self): 242 self.host_query_manager = AvailableHostQueryManager() 243 self.cache = rdb_cache_manager.RDBHostCacheManager() 244 self.response_map = {} 245 self.unsatisfied_requests = 0 246 self.leased_hosts_count = 0 247 self.request_accountant = None 248 249 250 @_timer.decorate 251 def lease_hosts(self, hosts): 252 """Leases a list of hosts. 253 254 @param hosts: A list of RDBServerHostWrapper instances to lease. 255 256 @return: The list of RDBServerHostWrappers that were successfully 257 leased. 258 """ 259 #TODO(beeps): crbug.com/353183. 260 unleased_hosts = set(hosts) 261 leased_hosts = set([]) 262 for host in unleased_hosts: 263 try: 264 host.lease() 265 except rdb_utils.RDBException as e: 266 logging.error('Unable to lease host %s: %s', host.hostname, e) 267 else: 268 leased_hosts.add(host) 269 return list(leased_hosts) 270 271 272 @classmethod 273 def valid_host_assignment(cls, request, host): 274 """Check if a host, request pairing is valid. 275 276 @param request: The request to match against the host. 277 @param host: An RDBServerHostWrapper instance. 278 279 @return: True if the host, request assignment is valid. 280 281 @raises RDBException: If the request already has another host_ids 282 associated with it. 283 """ 284 if request.host_id and request.host_id != host.id: 285 raise rdb_utils.RDBException( 286 'Cannot assign a different host for request: %s, it ' 287 'already has one: %s ' % (request, host.id)) 288 289 # Getting all labels and acls might result in large queries, so 290 # bail early if the host is already leased. 291 if host.leased: 292 return False 293 # If a host is invalid it must be a one time host added to the 294 # afe specifically for this purpose, so it doesn't require acl checking. 295 acl_match = (request.acls.intersection(host.acls) or host.invalid) 296 label_match = (request.deps.intersection(host.labels) == request.deps) 297 return acl_match and label_match 298 299 300 @classmethod 301 def _sort_hosts_by_preferred_deps(cls, hosts, preferred_deps): 302 """Sort hosts in the order of how many preferred deps it has. 303 304 This allows rdb always choose the hosts with the most preferred deps 305 for a request. One important use case is including cros-version as 306 a preferred dependence. By choosing a host with the same cros-version, 307 we can save the time on provisioning it. Note this is not guaranteed 308 if preferred_deps contains other labels as well. 309 310 @param hosts: A list of hosts to sort. 311 @param preferred_deps: A list of deps that are preferred. 312 313 @return: A list of sorted hosts. 314 315 """ 316 hosts = sorted( 317 hosts, 318 key=lambda host: len(set(preferred_deps) & set(host.labels)), 319 reverse=True) 320 return hosts 321 322 323 @rdb_cache_manager.memoize_hosts 324 def _acquire_hosts(self, request, hosts_required, is_acquire_min_duts=False, 325 **kwargs): 326 """Acquire hosts for a group of similar requests. 327 328 Find and acquire hosts that can satisfy a group of requests. 329 1. If the caching decorator doesn't pass in a list of matching hosts 330 via the MEMOIZE_KEY this method will directly check the database for 331 matching hosts. 332 2. If all matching hosts are not leased for this request, the remaining 333 hosts are returned to the caching decorator, to place in the cache. 334 335 @param hosts_required: Number of hosts required to satisfy request. 336 @param request: The request for hosts. 337 @param is_acquire_min_duts: Boolean. Indicate whether this is to 338 acquire minimum required duts, only used 339 for stats purpose. 340 341 @return: The list of excess matching hosts. 342 """ 343 hosts = kwargs.get(rdb_cache_manager.MEMOIZE_KEY, []) 344 if not hosts: 345 hosts = self.host_query_manager.find_hosts( 346 request.deps, request.acls) 347 348 # <-----[:attempt_lease_hosts](evicted)--------> <-(returned, cached)-> 349 # | -leased_hosts- | -stale cached hosts- | -unleased matching- | 350 # --used this request---used by earlier request----------unused-------- 351 hosts = self._sort_hosts_by_preferred_deps( 352 hosts, request.preferred_deps) 353 attempt_lease_hosts = min(len(hosts), hosts_required) 354 leased_host_count = 0 355 if attempt_lease_hosts: 356 leased_hosts = self.lease_hosts(hosts[:attempt_lease_hosts]) 357 if leased_hosts: 358 self.update_response_map(request, leased_hosts, append=True) 359 360 # [:attempt_leased_hosts] - leased_hosts will include hosts that 361 # failed leasing, most likely because they're already leased, so 362 # don't cache them again. 363 leased_host_count = len(leased_hosts) 364 failed_leasing = attempt_lease_hosts - leased_host_count 365 if failed_leasing > 0: 366 # For the sake of simplicity this calculation assumes that 367 # leasing only fails if there's a stale cached host already 368 # leased by a previous request, ergo, we can only get here 369 # through a cache hit. 370 line_length = len(hosts) 371 self.cache.stale_entries.append( 372 (float(failed_leasing)/line_length) * 100) 373 self.leased_hosts_count += leased_host_count 374 if is_acquire_min_duts: 375 self.request_accountant.record_acquire_min_duts( 376 request, hosts_required, leased_host_count) 377 self.unsatisfied_requests += max(hosts_required - leased_host_count, 0) 378 # Cache the unleased matching hosts against the request. 379 return hosts[attempt_lease_hosts:] 380 381 382 @_timer.decorate 383 def batch_acquire_hosts(self, host_requests): 384 """Acquire hosts for a list of requests. 385 386 The act of acquisition involves finding and leasing a set of hosts 387 that match the parameters of a request. Each acquired host is added 388 to the response_map dictionary as an RDBServerHostWrapper. 389 390 @param host_requests: A list of requests to acquire hosts. 391 """ 392 distinct_requests = 0 393 394 logging.debug('Processing %s host acquisition requests', 395 len(host_requests)) 396 397 self.request_accountant = rdb_utils.RequestAccountant(host_requests) 398 # First pass tries to satisfy min_duts for each suite. 399 for request in self.request_accountant.requests: 400 to_acquire = self.request_accountant.get_min_duts(request) 401 if to_acquire > 0: 402 self._acquire_hosts(request, to_acquire, 403 is_acquire_min_duts=True) 404 distinct_requests += 1 405 406 # Second pass tries to allocate duts to the rest unsatisfied requests. 407 for request in self.request_accountant.requests: 408 to_acquire = self.request_accountant.get_duts(request) 409 if to_acquire > 0: 410 self._acquire_hosts(request, to_acquire, 411 is_acquire_min_duts=False) 412 413 self.cache.record_stats() 414 logging.debug('Host acquisition stats: distinct requests: %s, leased ' 415 'hosts: %s, unsatisfied requests: %s', distinct_requests, 416 self.leased_hosts_count, self.unsatisfied_requests) 417 autotest_stats.Gauge(rdb_utils.RDB_STATS_KEY).send( 418 'leased_hosts', self.leased_hosts_count) 419 autotest_stats.Gauge(rdb_utils.RDB_STATS_KEY).send( 420 'unsatisfied_requests', self.unsatisfied_requests) 421 422 423 @_timer.decorate 424 def batch_validate_hosts(self, requests): 425 """Validate requests with hosts. 426 427 Reserve all hosts, check each one for validity and discard invalid 428 request-host pairings. Lease the remaining hsots. 429 430 @param requests: A list of requests to validate. 431 432 @raises RDBException: If multiple hosts or the wrong host is returned 433 for a response. 434 """ 435 # The following cases are possible for frontend requests: 436 # 1. Multiple requests for 1 host, with different acls/deps/priority: 437 # These form distinct requests because they hash differently. 438 # The response map will contain entries like: {r1: h1, r2: h1} 439 # after the batch_get_hosts call. There are 2 sub-cases: 440 # a. Same deps/acls, different priority: 441 # Since we sort the requests based on priority, the 442 # higher priority request r1, will lease h1. The 443 # validation of r2, h1 will fail because of the r1 lease. 444 # b. Different deps/acls, only one of which matches the host: 445 # The matching request will lease h1. The other host 446 # pairing will get dropped from the response map. 447 # 2. Multiple requests with the same acls/deps/priority and 1 host: 448 # These all have the same request hash, so the response map will 449 # contain: {r: h}, regardless of the number of r's. If this is not 450 # a valid host assignment it will get dropped from the response. 451 self.batch_get_hosts(set(requests)) 452 for request in sorted(self.response_map.keys(), 453 key=lambda request: request.priority, reverse=True): 454 hosts = self.response_map[request] 455 if len(hosts) > 1: 456 raise rdb_utils.RDBException('Got multiple hosts for a single ' 457 'request. Hosts: %s, request %s.' % (hosts, request)) 458 # Job-shard is 1:1 mapping. Because a job can only belongs 459 # to one shard, or belongs to master, we disallow frontend job 460 # that spans hosts on and off shards or across multiple shards, 461 # which would otherwise break the 1:1 mapping. 462 # As such, on master, if a request asks for multiple hosts and 463 # if any host is found on shard, we assume other requested hosts 464 # would also be on the same shard. We can safely drop this request. 465 ignore_request = _is_master and any( 466 [host.shard_id for host in hosts]) 467 if (not ignore_request and 468 (self.valid_host_assignment(request, hosts[0]) and 469 self.lease_hosts(hosts))): 470 continue 471 del self.response_map[request] 472 logging.warning('Request %s was not able to lease host %s', 473 request, hosts[0]) 474 475 476# Request dispatchers: Create the appropriate request handler, send a list 477# of requests to one of its methods. The corresponding request handler in 478# rdb_lib must understand how to match each request with a response from a 479# dispatcher, the easiest way to achieve this is to returned the response_map 480# attribute of the request handler, after making the appropriate requests. 481def get_hosts(host_requests): 482 """Get host information about the requested hosts. 483 484 @param host_requests: A list of requests as defined in BaseHostRequest. 485 @return: A dictionary mapping each request to a list of hosts. 486 """ 487 rdb_handler = BaseHostRequestHandler() 488 rdb_handler.batch_get_hosts(host_requests) 489 return rdb_handler.get_response() 490 491 492def update_hosts(update_requests): 493 """Update hosts. 494 495 @param update_requests: A list of updates to host tables 496 as defined in UpdateHostRequest. 497 """ 498 rdb_handler = BaseHostRequestHandler() 499 rdb_handler.update_hosts(update_requests) 500 return rdb_handler.get_response() 501 502 503def rdb_host_request_dispatcher(host_requests): 504 """Dispatcher for all host acquisition queries. 505 506 @param host_requests: A list of requests for acquiring hosts, as defined in 507 AcquireHostRequest. 508 @return: A dictionary mapping each request to a list of hosts, or 509 an empty list if none could satisfy the request. Eg: 510 {AcquireHostRequest.template: [host_info_dictionaries]} 511 """ 512 validation_requests = [] 513 require_hosts_requests = [] 514 515 # Validation requests are made by a job scheduled against a specific host 516 # specific host (eg: through the frontend) and only require the rdb to 517 # match the parameters of the host against the request. Acquisition 518 # requests are made by jobs that need hosts (eg: suites) and the rdb needs 519 # to find hosts matching the parameters of the request. 520 for request in host_requests: 521 if request.host_id: 522 validation_requests.append(request) 523 else: 524 require_hosts_requests.append(request) 525 526 rdb_handler = AvailableHostRequestHandler() 527 rdb_handler.batch_validate_hosts(validation_requests) 528 rdb_handler.batch_acquire_hosts(require_hosts_requests) 529 return rdb_handler.get_response() 530