Home | History | Annotate | Download | only in build
      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