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.ddmlib.Log; 19 import com.android.tradefed.command.FatalHostError; 20 import com.android.tradefed.log.LogUtil.CLog; 21 import com.android.tradefed.util.FileUtil; 22 23 import com.google.common.annotations.VisibleForTesting; 24 25 import java.io.File; 26 import java.io.IOException; 27 import java.util.Collections; 28 import java.util.Comparator; 29 import java.util.HashMap; 30 import java.util.Iterator; 31 import java.util.LinkedHashMap; 32 import java.util.LinkedList; 33 import java.util.List; 34 import java.util.Map; 35 import java.util.Stack; 36 import java.util.concurrent.TimeUnit; 37 import java.util.concurrent.locks.ReentrantLock; 38 39 /** 40 * A helper class that maintains a local filesystem LRU cache of downloaded files. 41 */ 42 public class FileDownloadCache { 43 44 private static final String LOG_TAG = "FileDownloadCache"; 45 46 private static final char REL_PATH_SEPARATOR = '/'; 47 48 /** fixed location of download cache. */ 49 private final File mCacheRoot; 50 51 /** 52 * The map of remote file paths to local files, stored in least-recently-used order. 53 * <p/> 54 * Used for performance reasons. Functionally speaking, this data structure is not needed, 55 * since all info could be obtained from inspecting the filesystem. 56 */ 57 private final Map<String, File> mCacheMap = new LinkedHashMap<String, File>(); 58 59 /** the lock for <var>mCacheMap</var> */ 60 private final ReentrantLock mCacheMapLock = new ReentrantLock(); 61 62 /** A map of remote file paths to locks. */ 63 private final Map<String, ReentrantLock> mFileLocks = new HashMap<String, ReentrantLock>(); 64 65 private long mCurrentCacheSize = 0; 66 67 /** The approximate maximum allowed size of the local file cache. Default to 20 gig */ 68 private long mMaxFileCacheSize = 20L * 1024L * 1024L * 1024L; 69 70 /** 71 * Struct for a {@link File} and its remote relative path 72 */ 73 private static class FilePair { 74 final String mRelPath; 75 final File mFile; 76 77 FilePair(String relPath, File file) { 78 mRelPath = relPath; 79 mFile = file; 80 } 81 } 82 83 /** 84 * A {@link Comparator} for comparing {@link File}s based on {@link File#lastModified()}. 85 */ 86 private static class FileTimeComparator implements Comparator<FilePair> { 87 @Override 88 public int compare(FilePair o1, FilePair o2) { 89 Long timestamp1 = Long.valueOf(o1.mFile.lastModified()); 90 Long timestamp2 = o2.mFile.lastModified(); 91 return timestamp1.compareTo(timestamp2); 92 } 93 } 94 95 /** 96 * Create a {@link FileDownloadCache}, deleting any previous cache contents from disk. 97 * <p/> 98 * Assumes that the current process has exclusive access to the <var>cacheRoot</var> directory. 99 * <p/> 100 * Essentially, the LRU cache is a mirror of a given remote file path hierarchy. 101 */ 102 FileDownloadCache(File cacheRoot) { 103 mCacheRoot = cacheRoot; 104 if (!mCacheRoot.exists()) { 105 Log.d(LOG_TAG, String.format("Creating file cache at %s", 106 mCacheRoot.getAbsolutePath())); 107 if (!mCacheRoot.mkdirs()) { 108 throw new FatalHostError(String.format("Could not create cache directory at %s", 109 mCacheRoot.getAbsolutePath())); 110 } 111 } else { 112 Log.d(LOG_TAG, String.format("Building file cache from contents at %s", 113 mCacheRoot.getAbsolutePath())); 114 // create an unsorted list of all the files in mCacheRoot. Need to create list first 115 // rather than inserting in Map directly because Maps cannot be sorted 116 List<FilePair> cacheEntryList = new LinkedList<FilePair>(); 117 addFiles(mCacheRoot, new Stack<String>(), cacheEntryList); 118 // now sort them based on file timestamp, to get them in LRU order 119 Collections.sort(cacheEntryList, new FileTimeComparator()); 120 // now insert them into the map 121 for (FilePair cacheEntry : cacheEntryList) { 122 mCacheMap.put(cacheEntry.mRelPath, cacheEntry.mFile); 123 mCurrentCacheSize += cacheEntry.mFile.length(); 124 } 125 // this would be an unusual situation, but check if current cache is already too big 126 if (mCurrentCacheSize > getMaxFileCacheSize()) { 127 incrementAndAdjustCache(0); 128 } 129 } 130 } 131 132 /** 133 * Recursive method for adding a directory's contents to the cache map 134 * <p/> 135 * cacheEntryList will contain results of all files found in cache, in no guaranteed order. 136 * 137 * @param dir the parent directory to search 138 * @param relPathSegments the current filesystem path of <var>dir</var>, relative to 139 * <var>mCacheRoot</var> 140 * @param cacheEntryList the list of files discovered 141 */ 142 private void addFiles(File dir, Stack<String> relPathSegments, 143 List<FilePair> cacheEntryList) { 144 145 File[] fileList = dir.listFiles(); 146 if (fileList == null) { 147 CLog.e("Unable to list files in cache dir %s", dir.getAbsolutePath()); 148 return; 149 } 150 for (File childFile : fileList) { 151 if (childFile.isDirectory()) { 152 relPathSegments.push(childFile.getName()); 153 addFiles(childFile, relPathSegments, cacheEntryList); 154 relPathSegments.pop(); 155 } else if (childFile.isFile()) { 156 StringBuffer relPath = new StringBuffer(); 157 for (String pathSeg : relPathSegments) { 158 relPath.append(pathSeg); 159 relPath.append(REL_PATH_SEPARATOR); 160 } 161 relPath.append(childFile.getName()); 162 cacheEntryList.add(new FilePair(relPath.toString(), childFile)); 163 } else { 164 Log.w(LOG_TAG, String.format("Unrecognized file type %s in cache", 165 childFile.getAbsolutePath())); 166 } 167 } 168 } 169 170 /** Acquires the lock for a file. */ 171 protected void lockFile(String remoteFilePath) { 172 ReentrantLock fileLock; 173 174 synchronized (mFileLocks) { 175 fileLock = mFileLocks.get(remoteFilePath); 176 if (fileLock == null) { 177 fileLock = new ReentrantLock(); 178 mFileLocks.put(remoteFilePath, fileLock); 179 } 180 } 181 fileLock.lock(); 182 } 183 184 /** 185 * Acquire the lock for a file only if it is not held by another thread. 186 * 187 * @return true if the lock was acquired, and false otherwise. 188 */ 189 protected boolean tryLockFile(String remoteFilePath) { 190 synchronized (mFileLocks) { 191 ReentrantLock fileLock = mFileLocks.get(remoteFilePath); 192 if (fileLock == null) { 193 fileLock = new ReentrantLock(); 194 mFileLocks.put(remoteFilePath, fileLock); 195 } 196 try { 197 return fileLock.tryLock(0, TimeUnit.SECONDS); 198 } catch (InterruptedException e) { 199 return false; 200 } 201 } 202 } 203 204 /** Attempt to release a lock for a file. */ 205 protected void unlockFile(String remoteFilePath) { 206 synchronized (mFileLocks) { 207 ReentrantLock fileLock = mFileLocks.get(remoteFilePath); 208 if (fileLock != null) { 209 if (!fileLock.hasQueuedThreads()) { 210 mFileLocks.remove(remoteFilePath); 211 } 212 fileLock.unlock(); 213 } 214 } 215 } 216 217 /** 218 * Set the maximum size of the local file cache. 219 * 220 * <p>Cache will not be adjusted immediately if set to a smaller size than current, but will 221 * take effect on next file download. 222 * 223 * @param numBytes 224 */ 225 public void setMaxCacheSize(long numBytes) { 226 // for simplicity, get global lock 227 mCacheMapLock.lock(); 228 mMaxFileCacheSize = numBytes; 229 mCacheMapLock.unlock(); 230 } 231 232 /** 233 * Returns a local file corresponding to the given <var>remotePath</var> 234 * <p/> 235 * The local {@link File} will be copied from the cache if it exists, otherwise will be 236 * downloaded via the given {@link IFileDownloader}. 237 * 238 * @param downloader the {@link IFileDownloader} 239 * @param remotePath the remote file. 240 * @return a local {@link File} containing contents of remotePath 241 * @throws BuildRetrievalError if file could not be retrieved 242 */ 243 public File fetchRemoteFile(IFileDownloader downloader, String remotePath) 244 throws BuildRetrievalError { 245 boolean download = false; 246 File cachedFile, copyFile; 247 248 lockFile(remotePath); 249 try { 250 mCacheMapLock.lock(); 251 try { 252 cachedFile = mCacheMap.remove(remotePath); 253 if (cachedFile == null) { 254 download = true; 255 String localRelativePath = convertPath(remotePath); 256 cachedFile = new File(mCacheRoot, localRelativePath); 257 } 258 mCacheMap.put(remotePath, cachedFile); 259 } finally { 260 mCacheMapLock.unlock(); 261 } 262 263 try { 264 if (download || !cachedFile.exists()) { 265 cachedFile.getParentFile().mkdirs(); 266 downloadFile(downloader, remotePath, cachedFile); 267 } else { 268 Log.d( 269 LOG_TAG, 270 String.format( 271 "Retrieved remote file %s from cached file %s", 272 remotePath, cachedFile.getAbsolutePath())); 273 } 274 copyFile = copyFile(remotePath, cachedFile); 275 } catch (BuildRetrievalError | RuntimeException e) { 276 // cached file is likely incomplete, delete it. 277 deleteCacheEntry(remotePath); 278 throw e; 279 } 280 281 // Only the thread that first downloads the file should increment the cache. 282 if (download) { 283 incrementAndAdjustCache(cachedFile.length()); 284 } 285 } finally { 286 unlockFile(remotePath); 287 } 288 return copyFile; 289 } 290 291 /** Do the actual file download, clean up on exception is done by the caller. */ 292 private void downloadFile(IFileDownloader downloader, String remotePath, File cachedFile) 293 throws BuildRetrievalError { 294 Log.d(LOG_TAG, String.format("Downloading %s to cache", remotePath)); 295 downloader.downloadFile(remotePath, cachedFile); 296 } 297 298 @VisibleForTesting 299 File copyFile(String remotePath, File cachedFile) throws BuildRetrievalError { 300 // attempt to create a local copy of cached file with sane name 301 File hardlinkFile = null; 302 try { 303 hardlinkFile = FileUtil.createTempFileForRemote(remotePath, null); 304 hardlinkFile.delete(); 305 CLog.d("Creating hardlink '%s' to '%s'", hardlinkFile.getAbsolutePath(), 306 cachedFile.getAbsolutePath()); 307 FileUtil.hardlinkFile(cachedFile, hardlinkFile); 308 return hardlinkFile; 309 } catch (IOException e) { 310 FileUtil.deleteFile(hardlinkFile); 311 // cached file might be corrupt or incomplete, delete it 312 FileUtil.deleteFile(cachedFile); 313 throw new BuildRetrievalError(String.format("Failed to copy cached file %s", 314 cachedFile), e); 315 } 316 } 317 318 /** 319 * Convert remote relative path into an equivalent local path 320 * @param remotePath 321 * @return the local relative path 322 */ 323 private String convertPath(String remotePath) { 324 if (FileDownloadCache.REL_PATH_SEPARATOR != File.separatorChar) { 325 return remotePath.replace(FileDownloadCache.REL_PATH_SEPARATOR , File.separatorChar); 326 } else { 327 // no conversion necessary 328 return remotePath; 329 } 330 } 331 332 /** 333 * Adjust file cache size to mMaxFileCacheSize if necessary by deleting old files 334 */ 335 private void incrementAndAdjustCache(long length) { 336 mCacheMapLock.lock(); 337 try { 338 mCurrentCacheSize += length; 339 Iterator<String> keyIterator = mCacheMap.keySet().iterator(); 340 while (mCurrentCacheSize > getMaxFileCacheSize() && keyIterator.hasNext()) { 341 String remotePath = keyIterator.next(); 342 // Only delete the file if it is not being used by another thread. 343 if (tryLockFile(remotePath)) { 344 try { 345 File file = mCacheMap.get(remotePath); 346 mCurrentCacheSize -= file.length(); 347 file.delete(); 348 keyIterator.remove(); 349 } finally { 350 unlockFile(remotePath); 351 } 352 } else { 353 CLog.i( 354 String.format( 355 "File %s is being used by another invocation. Skipping.", 356 remotePath)); 357 } 358 } 359 // audit cache size 360 if (mCurrentCacheSize < 0) { 361 // should never happen 362 Log.e(LOG_TAG, "Cache size is less than 0!"); 363 // TODO: throw fatal error? 364 } else if (mCurrentCacheSize > getMaxFileCacheSize()) { 365 // May occur if the cache is configured to be too small or if mCurrentCacheSize is 366 // accounting for non-existent files. 367 Log.w(LOG_TAG, "File cache is over-capacity."); 368 } 369 } finally { 370 mCacheMapLock.unlock(); 371 } 372 } 373 374 /** 375 * Returns the cached file for given remote path, or <code>null</code> if no cached file exists. 376 * <p/> 377 * Exposed for unit testing 378 * 379 * @param remoteFilePath the remote file path 380 * @return the cached {@link File} or <code>null</code> 381 */ 382 File getCachedFile(String remoteFilePath) { 383 mCacheMapLock.lock(); 384 try { 385 return mCacheMap.get(remoteFilePath); 386 } finally { 387 mCacheMapLock.unlock(); 388 } 389 } 390 391 /** 392 * Empty the cache, deleting all files. 393 * <p/> 394 * exposed for unit testing 395 */ 396 void empty() { 397 long currentMax = getMaxFileCacheSize(); 398 // reuse incrementAndAdjustCache to clear cache, by setting cache cap to 0 399 setMaxCacheSize(0L); 400 incrementAndAdjustCache(0); 401 setMaxCacheSize(currentMax); 402 } 403 404 /** 405 * Retrieve the oldest remotePath from cache. 406 * <p/> 407 * Exposed for unit testing 408 * 409 * @return the remote path or <code>null</null> if cache is empty 410 */ 411 String getOldestEntry() { 412 mCacheMapLock.lock(); 413 try { 414 if (!mCacheMap.isEmpty()) { 415 return mCacheMap.keySet().iterator().next(); 416 } else { 417 return null; 418 } 419 } finally { 420 mCacheMapLock.unlock(); 421 } 422 } 423 424 /** 425 * Get the current max size of file cache. 426 * <p/> 427 * exposed for unit testing. 428 * 429 * @return the mMaxFileCacheSize 430 */ 431 long getMaxFileCacheSize() { 432 return mMaxFileCacheSize; 433 } 434 435 /** 436 * Allow deleting an entry from the cache. In case the entry is invalid or corrupted. 437 */ 438 public void deleteCacheEntry(String remoteFilePath) { 439 lockFile(remoteFilePath); 440 try { 441 mCacheMapLock.lock(); 442 try { 443 File file = mCacheMap.remove(remoteFilePath); 444 if (file != null) { 445 FileUtil.recursiveDelete(file); 446 } else { 447 CLog.i("No cache entry to delete for %s", remoteFilePath); 448 } 449 } finally { 450 mCacheMapLock.unlock(); 451 } 452 } finally { 453 unlockFile(remoteFilePath); 454 } 455 } 456 } 457