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