1 /* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.tradefed.build; 17 18 import com.android.tradefed.command.FatalHostError; 19 import com.android.tradefed.config.GlobalConfiguration; 20 import com.android.tradefed.invoker.logger.CurrentInvocation; 21 import com.android.tradefed.invoker.logger.CurrentInvocation.InvocationInfo; 22 import com.android.tradefed.invoker.logger.InvocationMetricLogger; 23 import com.android.tradefed.invoker.logger.InvocationMetricLogger.InvocationMetricKey; 24 import com.android.tradefed.invoker.tracing.CloseableTraceScope; 25 import com.android.tradefed.log.LogUtil.CLog; 26 import com.android.tradefed.result.error.InfraErrorIdentifier; 27 import com.android.tradefed.util.FileUtil; 28 import com.android.tradefed.util.StreamUtil; 29 30 import com.google.common.annotations.VisibleForTesting; 31 32 import java.io.File; 33 import java.io.IOException; 34 import java.nio.channels.FileChannel; 35 import java.nio.channels.FileLock; 36 import java.nio.file.StandardOpenOption; 37 import java.util.Collections; 38 import java.util.Comparator; 39 import java.util.Iterator; 40 import java.util.LinkedHashMap; 41 import java.util.LinkedList; 42 import java.util.List; 43 import java.util.Map; 44 import java.util.Stack; 45 import java.util.concurrent.TimeUnit; 46 import java.util.concurrent.locks.ReentrantLock; 47 48 /** 49 * A helper class that maintains a local filesystem LRU cache of downloaded files. 50 */ 51 public class FileDownloadCache { 52 53 private static final char REL_PATH_SEPARATOR = '/'; 54 55 /** fixed location of download cache. */ 56 private final File mCacheRoot; 57 58 /** 59 * The map of remote file paths to local files, stored in least-recently-used order. 60 * 61 * <p>Used for performance reasons. Functionally speaking, this data structure is not needed, 62 * since all info could be obtained from inspecting the filesystem. 63 */ 64 private final Map<String, File> mCacheMap = new CollapsedKeyMap<>(); 65 66 /** the lock for <var>mCacheMap</var> */ 67 private final ReentrantLock mCacheMapLock = new ReentrantLock(); 68 69 /** A map of remote file paths to locks. */ 70 private final Map<String, ReentrantLock> mFileLocks = new CollapsedKeyMap<>(); 71 72 private final Map<String, FileLock> mJvmLocks = new CollapsedKeyMap<>(); 73 74 private long mCurrentCacheSize = 0; 75 76 /** The approximate maximum allowed size of the local file cache. Default to 20 gig */ 77 private long mMaxFileCacheSize = 78 GlobalConfiguration.getInstance().getHostOptions().getCacheSizeLimit(); 79 80 /** 81 * Struct for a {@link File} and its remote relative path 82 */ 83 private static class FilePair { 84 final String mRelPath; 85 final File mFile; 86 FilePair(String relPath, File file)87 FilePair(String relPath, File file) { 88 mRelPath = relPath; 89 mFile = file; 90 } 91 } 92 93 /** 94 * A {@link Comparator} for comparing {@link File}s based on {@link File#lastModified()}. 95 */ 96 private static class FileTimeComparator implements Comparator<FilePair> { 97 @Override compare(FilePair o1, FilePair o2)98 public int compare(FilePair o1, FilePair o2) { 99 Long timestamp1 = Long.valueOf(o1.mFile.lastModified()); 100 Long timestamp2 = o2.mFile.lastModified(); 101 return timestamp1.compareTo(timestamp2); 102 } 103 } 104 105 /** 106 * Create a {@link FileDownloadCache}, deleting any previous cache contents from disk. 107 * <p/> 108 * Assumes that the current process has exclusive access to the <var>cacheRoot</var> directory. 109 * <p/> 110 * Essentially, the LRU cache is a mirror of a given remote file path hierarchy. 111 */ FileDownloadCache(File cacheRoot)112 FileDownloadCache(File cacheRoot) { 113 mCacheRoot = cacheRoot; 114 if (!mCacheRoot.exists()) { 115 CLog.d("Creating file cache at %s", mCacheRoot.getAbsolutePath()); 116 if (!mCacheRoot.mkdirs()) { 117 throw new FatalHostError( 118 String.format( 119 "Could not create cache directory at %s", 120 mCacheRoot.getAbsolutePath()), 121 InfraErrorIdentifier.LAB_HOST_FILESYSTEM_ERROR); 122 } 123 } else { 124 mCacheMapLock.lock(); 125 try { 126 CLog.d("Building file cache from contents at %s", mCacheRoot.getAbsolutePath()); 127 // create an unsorted list of all the files in mCacheRoot. Need to create list first 128 // rather than inserting in Map directly because Maps cannot be sorted 129 List<FilePair> cacheEntryList = new LinkedList<FilePair>(); 130 addFiles(mCacheRoot, new Stack<String>(), cacheEntryList); 131 // now sort them based on file timestamp, to get them in LRU order 132 Collections.sort(cacheEntryList, new FileTimeComparator()); 133 // now insert them into the map 134 for (FilePair cacheEntry : cacheEntryList) { 135 mCacheMap.put(cacheEntry.mRelPath, cacheEntry.mFile); 136 mCurrentCacheSize += cacheEntry.mFile.length(); 137 } 138 // this would be an unusual situation, but check if current cache is already too big 139 if (mCurrentCacheSize > getMaxFileCacheSize()) { 140 incrementAndAdjustCache(0); 141 } 142 } finally { 143 mCacheMapLock.unlock(); 144 } 145 } 146 } 147 148 /** 149 * Recursive method for adding a directory's contents to the cache map 150 * <p/> 151 * cacheEntryList will contain results of all files found in cache, in no guaranteed order. 152 * 153 * @param dir the parent directory to search 154 * @param relPathSegments the current filesystem path of <var>dir</var>, relative to 155 * <var>mCacheRoot</var> 156 * @param cacheEntryList the list of files discovered 157 */ addFiles(File dir, Stack<String> relPathSegments, List<FilePair> cacheEntryList)158 private void addFiles(File dir, Stack<String> relPathSegments, 159 List<FilePair> cacheEntryList) { 160 161 File[] fileList = dir.listFiles(); 162 if (fileList == null) { 163 CLog.e("Unable to list files in cache dir %s", dir.getAbsolutePath()); 164 return; 165 } 166 for (File childFile : fileList) { 167 if (childFile.isDirectory()) { 168 relPathSegments.push(childFile.getName()); 169 addFiles(childFile, relPathSegments, cacheEntryList); 170 relPathSegments.pop(); 171 } else if (childFile.isFile()) { 172 StringBuffer relPath = new StringBuffer(); 173 for (String pathSeg : relPathSegments) { 174 relPath.append(pathSeg); 175 relPath.append(REL_PATH_SEPARATOR); 176 } 177 relPath.append(childFile.getName()); 178 cacheEntryList.add(new FilePair(relPath.toString(), childFile)); 179 } else { 180 CLog.w("Unrecognized file type %s in cache", childFile.getAbsolutePath()); 181 } 182 } 183 } 184 185 /** Acquires the lock for a file. */ lockFile(String remoteFilePath)186 protected void lockFile(String remoteFilePath) { 187 // Get a JVM level lock first 188 synchronized (mJvmLocks) { 189 FileLock fLock = mJvmLocks.get(remoteFilePath); 190 if (fLock == null) { 191 File f = new File(mCacheRoot, convertPath(remoteFilePath)); 192 // We can't lock a directory 193 if (!f.isDirectory()) { 194 try { 195 f.getParentFile().mkdirs(); 196 f.createNewFile(); 197 fLock = FileChannel.open(f.toPath(), StandardOpenOption.WRITE).lock(); 198 mJvmLocks.put(remoteFilePath, fLock); 199 } catch (IOException e) { 200 CLog.e(e); 201 } 202 } 203 } 204 } 205 // Get concurrent lock for inside the JVM 206 ReentrantLock fileLock; 207 synchronized (mFileLocks) { 208 fileLock = mFileLocks.get(remoteFilePath); 209 if (fileLock == null) { 210 fileLock = new ReentrantLock(); 211 mFileLocks.put(remoteFilePath, fileLock); 212 } 213 } 214 fileLock.lock(); 215 } 216 217 /** 218 * Acquire the lock for a file only if it is not held by another thread. 219 * 220 * @return true if the lock was acquired, and false otherwise. 221 */ tryLockFile(String remoteFilePath)222 protected boolean tryLockFile(String remoteFilePath) { 223 synchronized (mJvmLocks) { 224 FileLock fLock = mJvmLocks.get(remoteFilePath); 225 if (fLock == null) { 226 File f = new File(mCacheRoot, convertPath(remoteFilePath)); 227 // We can't lock a directory 228 if (f.exists() && !f.isDirectory()) { 229 try { 230 fLock = FileChannel.open(f.toPath(), StandardOpenOption.WRITE).tryLock(); 231 mJvmLocks.put(remoteFilePath, fLock); 232 } catch (IOException e) { 233 CLog.e(e); 234 } 235 } 236 } 237 if (fLock == null) { 238 return false; 239 } 240 } 241 synchronized (mFileLocks) { 242 ReentrantLock fileLock = mFileLocks.get(remoteFilePath); 243 if (fileLock == null) { 244 fileLock = new ReentrantLock(); 245 mFileLocks.put(remoteFilePath, fileLock); 246 } 247 try { 248 return fileLock.tryLock(0, TimeUnit.SECONDS); 249 } catch (InterruptedException e) { 250 return false; 251 } 252 } 253 } 254 255 /** Attempt to release a lock for a file. */ unlockFile(String remoteFilePath)256 protected void unlockFile(String remoteFilePath) { 257 synchronized (mFileLocks) { 258 ReentrantLock fileLock = mFileLocks.get(remoteFilePath); 259 if (fileLock != null) { 260 if (!fileLock.hasQueuedThreads()) { 261 mFileLocks.remove(remoteFilePath); 262 } 263 if (fileLock.isHeldByCurrentThread()) { 264 fileLock.unlock(); 265 } 266 } 267 } 268 // Release the JVM level lock 269 synchronized (mJvmLocks) { 270 FileLock fLock = mJvmLocks.get(remoteFilePath); 271 if (fLock != null) { 272 mJvmLocks.remove(remoteFilePath); 273 try { 274 fLock.release(); 275 } catch (IOException e) { 276 CLog.e(e); 277 } finally { 278 StreamUtil.close(fLock.channel()); 279 } 280 } 281 } 282 } 283 284 /** 285 * Set the maximum size of the local file cache. 286 * 287 * <p>Cache will not be adjusted immediately if set to a smaller size than current, but will 288 * take effect on next file download. 289 * 290 * @param numBytes 291 */ setMaxCacheSize(long numBytes)292 public void setMaxCacheSize(long numBytes) { 293 // for simplicity, get global lock 294 mCacheMapLock.lock(); 295 mMaxFileCacheSize = numBytes; 296 mCacheMapLock.unlock(); 297 } 298 299 /** 300 * Download the file or link the cache to the destination file. 301 * 302 * @param downloader the {@link IFileDownloader} 303 * @param remoteFilePath the remote file. 304 * @param destFile The destination file of the download. 305 * @throws BuildRetrievalError 306 */ fetchRemoteFile(IFileDownloader downloader, String remoteFilePath, File destFile)307 public void fetchRemoteFile(IFileDownloader downloader, String remoteFilePath, File destFile) 308 throws BuildRetrievalError { 309 internalfetchRemoteFile(downloader, remoteFilePath, destFile); 310 } 311 312 /** 313 * Returns a local file corresponding to the given <var>remotePath</var> 314 * 315 * <p>The local {@link File} will be copied from the cache if it exists, otherwise will be 316 * downloaded via the given {@link IFileDownloader}. 317 * 318 * @param downloader the {@link IFileDownloader} 319 * @param remoteFilePath the remote file. 320 * @return a local {@link File} containing contents of remotePath 321 * @throws BuildRetrievalError if file could not be retrieved 322 */ fetchRemoteFile(IFileDownloader downloader, String remoteFilePath)323 public File fetchRemoteFile(IFileDownloader downloader, String remoteFilePath) 324 throws BuildRetrievalError { 325 return internalfetchRemoteFile(downloader, remoteFilePath, null); 326 } 327 internalfetchRemoteFile( IFileDownloader downloader, String remotePath, File destFile)328 private File internalfetchRemoteFile( 329 IFileDownloader downloader, String remotePath, File destFile) 330 throws BuildRetrievalError { 331 boolean download = false; 332 File cachedFile; 333 File copyFile; 334 if (remotePath == null) { 335 throw new BuildRetrievalError( 336 "remote path was null.", InfraErrorIdentifier.ARTIFACT_REMOTE_PATH_NULL); 337 } 338 339 long start = System.currentTimeMillis(); 340 CloseableTraceScope scope = new CloseableTraceScope("cache_lock"); 341 lockFile(remotePath); 342 try { 343 mCacheMapLock.lock(); 344 try { 345 cachedFile = mCacheMap.remove(remotePath); 346 if (cachedFile == null) { 347 download = true; 348 String localRelativePath = convertPath(remotePath); 349 cachedFile = new File(mCacheRoot, localRelativePath); 350 } 351 mCacheMap.put(remotePath, cachedFile); 352 } finally { 353 mCacheMapLock.unlock(); 354 } 355 scope.close(); 356 InvocationMetricLogger.addInvocationMetrics( 357 InvocationMetricKey.CACHE_WAIT_FOR_LOCK, System.currentTimeMillis() - start); 358 try { 359 if (!download 360 && cachedFile.exists() 361 && (cachedFile.length() == 0L 362 || !downloader.isFresh(cachedFile, remotePath))) { 363 CLog.d( 364 "Cached file %s for %s is out of date, re-download.", 365 cachedFile, remotePath); 366 FileUtil.recursiveDelete(cachedFile); 367 download = true; 368 } 369 if (download || !cachedFile.exists()) { 370 cachedFile.getParentFile().mkdirs(); 371 // TODO: handle folder better 372 if (cachedFile.exists()) { 373 cachedFile.delete(); 374 } 375 downloadFile(downloader, remotePath, cachedFile); 376 } else { 377 InvocationMetricLogger.addInvocationMetrics( 378 InvocationMetricKey.CACHE_HIT_COUNT, 1); 379 CLog.d( 380 "Retrieved remote file %s from cached file %s", 381 remotePath, cachedFile.getAbsolutePath()); 382 } 383 copyFile = copyFile(remotePath, cachedFile, destFile); 384 } catch (BuildRetrievalError | RuntimeException e) { 385 // cached file is likely incomplete, delete it. 386 deleteCacheEntry(remotePath); 387 throw e; 388 } 389 390 // Only the thread that first downloads the file should increment the cache. 391 if (download) { 392 incrementAndAdjustCache(cachedFile.length()); 393 } 394 } finally { 395 unlockFile(remotePath); 396 } 397 return copyFile; 398 } 399 400 /** Do the actual file download, clean up on exception is done by the caller. */ downloadFile(IFileDownloader downloader, String remotePath, File cachedFile)401 private void downloadFile(IFileDownloader downloader, String remotePath, File cachedFile) 402 throws BuildRetrievalError { 403 CLog.d("Downloading %s to cache", remotePath); 404 downloader.downloadFile(remotePath, cachedFile); 405 InvocationMetricLogger.addInvocationMetrics( 406 InvocationMetricKey.ARTIFACTS_DOWNLOAD_SIZE, cachedFile.length()); 407 } 408 409 @VisibleForTesting copyFile(String remotePath, File cachedFile, File destFile)410 File copyFile(String remotePath, File cachedFile, File destFile) throws BuildRetrievalError { 411 // attempt to create a local copy of cached file with meaningful name 412 File hardlinkFile = destFile; 413 try { 414 if (hardlinkFile == null) { 415 hardlinkFile = FileUtil.createTempFileForRemote(remotePath, getWorkFolder()); 416 } 417 hardlinkFile.delete(); 418 CLog.d( 419 "Creating hardlink '%s' to '%s'", 420 hardlinkFile.getAbsolutePath(), cachedFile.getAbsolutePath()); 421 if (cachedFile.isDirectory()) { 422 FileUtil.recursiveHardlink(cachedFile, hardlinkFile, false); 423 } else { 424 FileUtil.hardlinkFile(cachedFile, hardlinkFile); 425 } 426 return hardlinkFile; 427 } catch (IOException e) { 428 FileUtil.deleteFile(hardlinkFile); 429 // cached file might be corrupt or incomplete, delete it 430 FileUtil.deleteFile(cachedFile); 431 throw new BuildRetrievalError( 432 String.format("Failed to copy cached file %s", cachedFile), 433 e, 434 InfraErrorIdentifier.FAIL_TO_CREATE_FILE); 435 } 436 } 437 438 @VisibleForTesting getWorkFolder()439 File getWorkFolder() { 440 return CurrentInvocation.getInfo(InvocationInfo.WORK_FOLDER); 441 } 442 443 /** 444 * Convert remote relative path into an equivalent local path 445 * @param remotePath 446 * @return the local relative path 447 */ convertPath(String remotePath)448 private String convertPath(String remotePath) { 449 if (FileDownloadCache.REL_PATH_SEPARATOR != File.separatorChar) { 450 return remotePath.replace(FileDownloadCache.REL_PATH_SEPARATOR , File.separatorChar); 451 } else { 452 // no conversion necessary 453 return remotePath; 454 } 455 } 456 457 /** 458 * Adjust file cache size to mMaxFileCacheSize if necessary by deleting old files 459 */ incrementAndAdjustCache(long length)460 private void incrementAndAdjustCache(long length) { 461 mCacheMapLock.lock(); 462 try { 463 mCurrentCacheSize += length; 464 Iterator<String> keyIterator = mCacheMap.keySet().iterator(); 465 while (mCurrentCacheSize > getMaxFileCacheSize() && keyIterator.hasNext()) { 466 String remotePath = keyIterator.next(); 467 // Only delete the file if it is not being used by another thread. 468 if (tryLockFile(remotePath)) { 469 try { 470 File file = mCacheMap.get(remotePath); 471 mCurrentCacheSize -= file.length(); 472 file.delete(); 473 keyIterator.remove(); 474 } finally { 475 unlockFile(remotePath); 476 } 477 } else { 478 CLog.i( 479 String.format( 480 "File %s is being used by another invocation. Skipping.", 481 remotePath)); 482 } 483 } 484 // audit cache size 485 if (mCurrentCacheSize < 0) { 486 // should never happen 487 CLog.e("Cache size is less than 0!"); 488 // TODO: throw fatal error? 489 } else if (mCurrentCacheSize > getMaxFileCacheSize()) { 490 // May occur if the cache is configured to be too small or if mCurrentCacheSize is 491 // accounting for non-existent files. 492 CLog.w("File cache is over-capacity."); 493 } 494 } finally { 495 mCacheMapLock.unlock(); 496 } 497 } 498 499 /** 500 * Returns the cached file for given remote path, or <code>null</code> if no cached file exists. 501 * <p/> 502 * Exposed for unit testing 503 * 504 * @param remoteFilePath the remote file path 505 * @return the cached {@link File} or <code>null</code> 506 */ getCachedFile(String remoteFilePath)507 File getCachedFile(String remoteFilePath) { 508 mCacheMapLock.lock(); 509 try { 510 return mCacheMap.get(remoteFilePath); 511 } finally { 512 mCacheMapLock.unlock(); 513 } 514 } 515 516 /** 517 * Empty the cache, deleting all files. 518 * <p/> 519 * exposed for unit testing 520 */ empty()521 void empty() { 522 long currentMax = getMaxFileCacheSize(); 523 // reuse incrementAndAdjustCache to clear cache, by setting cache cap to 0 524 setMaxCacheSize(0L); 525 incrementAndAdjustCache(0); 526 setMaxCacheSize(currentMax); 527 } 528 529 /** 530 * Retrieve the oldest remotePath from cache. 531 * <p/> 532 * Exposed for unit testing 533 * 534 * @return the remote path or <code>null</null> if cache is empty 535 */ getOldestEntry()536 String getOldestEntry() { 537 mCacheMapLock.lock(); 538 try { 539 if (!mCacheMap.isEmpty()) { 540 return mCacheMap.keySet().iterator().next(); 541 } else { 542 return null; 543 } 544 } finally { 545 mCacheMapLock.unlock(); 546 } 547 } 548 549 /** 550 * Get the current max size of file cache. 551 * <p/> 552 * exposed for unit testing. 553 * 554 * @return the mMaxFileCacheSize 555 */ getMaxFileCacheSize()556 long getMaxFileCacheSize() { 557 return mMaxFileCacheSize; 558 } 559 560 /** 561 * Allow deleting an entry from the cache. In case the entry is invalid or corrupted. 562 */ deleteCacheEntry(String remoteFilePath)563 public void deleteCacheEntry(String remoteFilePath) { 564 lockFile(remoteFilePath); 565 try { 566 mCacheMapLock.lock(); 567 try { 568 File file = mCacheMap.remove(remoteFilePath); 569 if (file != null) { 570 FileUtil.recursiveDelete(file); 571 } else { 572 CLog.i("No cache entry to delete for %s", remoteFilePath); 573 } 574 } finally { 575 mCacheMapLock.unlock(); 576 } 577 } finally { 578 unlockFile(remoteFilePath); 579 } 580 } 581 582 /** 583 * Class that ensure the remote file path as the key is always similar to an actual folder 584 * hierarchy. 585 */ 586 private static class CollapsedKeyMap<V> extends LinkedHashMap<String, V> { 587 @Override put(String key, V value)588 public V put(String key, V value) { 589 return super.put(new File(key).getPath(), value); 590 } 591 592 @Override get(Object key)593 public V get(Object key) { 594 if (key instanceof String) { 595 return super.get(new File((String) key).getPath()); 596 } 597 return super.get(key); 598 } 599 600 @Override remove(Object key)601 public V remove(Object key) { 602 if (key instanceof String) { 603 return super.remove(new File((String) key).getPath()); 604 } 605 return super.remove(key); 606 } 607 } 608 } 609