blob: c45f6c1c90ebf2befebb4ace340861e67d889ce4 [file] [log] [blame]
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.build.gradle.internal;
import static com.google.common.base.Preconditions.checkNotNull;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.build.gradle.options.BooleanOption;
import com.android.build.gradle.options.ProjectOptions;
import com.android.build.gradle.options.StringOption;
import com.android.builder.model.Version;
import com.android.builder.utils.FileCache;
import com.android.builder.utils.SynchronizedFile;
import com.android.ide.common.repository.GradleVersion;
import com.android.prefs.AndroidLocation;
import com.android.utils.FileUtils;
import com.android.utils.concurrency.ReadWriteProcessLock;
import com.google.common.base.Verify;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Duration;
import java.util.function.Function;
import java.util.function.Supplier;
import org.gradle.api.Project;
/**
* Class that contains utility methods for working with the build cache.
*/
public final class BuildCacheUtils {
@NonNull public static final String BUILD_CACHE_TROUBLESHOOTING_MESSAGE =
"To troubleshoot the issue or learn how to disable the build cache,"
+ " go to https://d.android.com/r/tools/build-cache.html.\n"
+ "If you are unable to fix the issue,"
+ " please file a bug at https://d.android.com/studio/report-bugs.html.";
/**
* The number of days that cache entries created by the current plugin version are kept since
* the last time they were created.
*/
private static final long CACHE_ENTRY_DAYS_TO_LIVE = 30;
/**
* The number of days that private cache directories created by previous plugin versions are
* kept since the last time they were used.
*
* <p>NOTE: This number should stay constant across plugin versions; if it is changed, we'll
* need to think about compatibility across plugin version.
*/
private static final long CACHE_DIRECTORY_DAYS_TO_LIVE = 30;
/**
* The number of days until the next cache eviction is performed. This is to avoid running cache
* eviction too frequently (e.g., with every build).
*/
private static final long DAYS_BETWEEN_CACHE_EVICTION_RUNS = 1;
/**
* The name of an empty marker file inside the current private cache directory whose timestamp
* indicates the last time cache eviction was run by the current plugin version.
*/
private static final String CACHE_EVICTION_MARKER_FILE_NAME = ".cache-eviction-marker";
/**
* The name of an empty marker file inside a private cache directory whose timestamp indicates
* the last time that cache directory was used.
*
* <p>NOTE: This file name should stay constant across plugin versions; if it is changed, we'll
* need to think about compatibility across plugin version.
*/
@VisibleForTesting static final String CACHE_USE_MARKER_FILE_NAME = ".cache-use-marker";
/**
* Returns a {@link FileCache} instance representing the build cache if the build cache is
* enabled, or null if it is disabled. If enabled, the build cache directory is set to a
* user-defined directory, or a default directory if the user-defined directory is not provided.
*
* <p>Cache eviction may be performed in this method at regular intervals (see {@link
* BuildCacheUtils#DAYS_BETWEEN_CACHE_EVICTION_RUNS}}).
*/
@SuppressWarnings("ResultOfMethodCallIgnored")
@Nullable
public static FileCache createBuildCacheIfEnabled(
@NonNull Project project, @NonNull ProjectOptions projectOptions) {
if (!projectOptions.get(BooleanOption.ENABLE_BUILD_CACHE)) {
return null;
}
// Use a default directory if the user-defined directory is not provided
Supplier<File> defaultBuildCacheDirSupplier = () -> {
try {
return new File(AndroidLocation.getFolder(), "build-cache");
} catch (AndroidLocation.AndroidLocationException e) {
throw new RuntimeException(e);
}
};
FileCache buildCache =
createBuildCache(
projectOptions.get(StringOption.BUILD_CACHE_DIR),
project.getRootProject()::file,
defaultBuildCacheDirSupplier,
Version.ANDROID_GRADLE_PLUGIN_VERSION);
// Get the shared directory containing the build caches for different plugin versions.
// In AGP 3.0.x and earlier, this directory contains the cache entries directly.
// In AGP 3.1.x and later, this directory contains one subdirectory for each plugin version,
// which then contains the cache entries for that version.
File sharedBuildCacheDir = buildCache.getCacheDirectory().getParentFile();
// Normalize the path in case different paths point to the same directory, this is important
// for the locking mechanism below to work reliably.
File normalizedSharedBuildCacheDir;
try {
normalizedSharedBuildCacheDir = sharedBuildCacheDir.getCanonicalFile();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
// Lock the shared build cache directory now for reading and unlock it later at the end
// of the build. This is to prevent the case where a plugin with version 3.0.x or
// earlier is deleting the shared cache directory while a plugin with version 3.1.x or
// later is using a private cache directory inside the shared cache directory.
String actionGroup =
BuildCacheUtils.class.getName() + "@" + normalizedSharedBuildCacheDir.getPath();
BuildSessionImpl.getSingleton()
.executeOnce(
actionGroup,
"lockSharedBuildCacheDir",
() -> {
// Since the file's path has been normalized, there is a 1-1
// correspondence between the cache directory and the lock file
File lockFile =
SynchronizedFile.getLockFile(normalizedSharedBuildCacheDir);
// Create the parent directory as required by ReadWriteProcessLock
FileUtils.mkdirs(lockFile.getParentFile());
ReadWriteProcessLock.Lock lock =
new ReadWriteProcessLock(lockFile.toPath()).readLock();
try {
lock.lock();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
BuildSessionImpl.getSingleton()
.executeOnceWhenBuildFinished(
actionGroup,
"unlockSharedBuildCacheDir",
() -> {
try {
lock.unlock();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
});
// Run cache eviction at regular intervals
boolean shouldRunCacheEviction =
shouldRunCacheEviction(
buildCache, Duration.ofDays(DAYS_BETWEEN_CACHE_EVICTION_RUNS));
if (shouldRunCacheEviction) {
// 1. Delete old cache entries created by the current plugin version
deleteOldCacheEntries(buildCache, Duration.ofDays(CACHE_ENTRY_DAYS_TO_LIVE));
// 2. Delete old cache directories created by previous plugin versions (back to and
// including 3.1.x)
deleteOldCacheDirectories(
sharedBuildCacheDir, Duration.ofDays(CACHE_DIRECTORY_DAYS_TO_LIVE));
// 3. Delete old cache entries created by plugin versions 3.0.x and earlier (in these
// versions, the cache entries are located directly under the shared build cache
// directory). Note that this operation requires a WRITE lock on the shared build cache
// directory, but the shared build cache directory is already being locked with a READ
// lock (see above). Therefore, we need to do this at the end of the build after the
// READ lock has been released; otherwise, it would deadlock as the READ lock cannot be
// promoted to a WRITE lock.
BuildSessionImpl.getSingleton()
.executeOnceWhenBuildFinished(
actionGroup,
"deleteOldCacheEntriesInSharedBuildCacheDir",
() ->
deleteOldCacheEntries(
FileCache.getInstanceWithMultiProcessLocking(
sharedBuildCacheDir),
Duration.ofDays(CACHE_DIRECTORY_DAYS_TO_LIVE)));
}
// Mark that the current cache was last used at this point
updateMarkerFile(new File(buildCache.getCacheDirectory(), CACHE_USE_MARKER_FILE_NAME));
return buildCache;
}
@NonNull
@VisibleForTesting
static FileCache createBuildCache(
@Nullable String buildCacheDir,
@NonNull Function<Object, File> pathToFileResolver,
@NonNull Supplier<File> defaultBuildCacheDirSupplier,
@NonNull String pluginVersion) {
File sharedBuildCacheDir =
buildCacheDir != null
? pathToFileResolver.apply(buildCacheDir)
: defaultBuildCacheDirSupplier.get();
// The actual build cache directory for a given plugin version is
// <shared-build-cache-dir>/<plugin-version>
return FileCache.getInstanceWithMultiProcessLocking(
new File(sharedBuildCacheDir, pluginVersion));
}
/**
* Determines whether cache eviction should be performed (if this is the first cache eviction
* request or the specified interval has elapsed since the last cache eviction request).
*
* @return {@code true} if cache eviction should be performed
*/
@VisibleForTesting
static boolean shouldRunCacheEviction(
@NonNull FileCache buildCache, @NonNull Duration cacheEvictionInterval) {
// Find out the last time cache eviction was run
File markerFile = new File(buildCache.getCacheDirectory(), CACHE_EVICTION_MARKER_FILE_NAME);
long lastEvictionTimestamp = markerFile.lastModified();
boolean shouldRunCacheEviction =
lastEvictionTimestamp == 0 /* The marker file does not yet exist */
|| Duration.ofMillis(System.currentTimeMillis() - lastEvictionTimestamp)
.compareTo(cacheEvictionInterval)
>= 0;
if (shouldRunCacheEviction) {
updateMarkerFile(markerFile);
}
return shouldRunCacheEviction;
}
/**
* Deletes all the cache entries in the given (private or shared) cache directory that have
* existed for the specified life time or longer.
*/
@VisibleForTesting
static void deleteOldCacheEntries(
@NonNull FileCache buildCache, @NonNull Duration cacheEntryLifeTime) {
// There could be a race condition here if another thread/process also reaches this point,
// but that is Okay because the method call below is thread-safe and process-safe, it's just
// that the code below will be executed more than once (which is fine).
buildCache.deleteOldCacheEntries(
System.currentTimeMillis() - cacheEntryLifeTime.toMillis());
}
/**
* Deletes all the private cache directories in the given shared cache directory that were
* created by previous plugin versions and have not been used for the specified life time or
* longer.
*/
@VisibleForTesting
static void deleteOldCacheDirectories(
@NonNull File sharedBuildCacheDir, @NonNull Duration cacheDirectoryLifeTime) {
GradleVersion currentPluginVersion =
Verify.verifyNotNull(
GradleVersion.tryParseAndroidGradlePluginVersion(
Version.ANDROID_GRADLE_PLUGIN_VERSION));
for (File buildCacheDir : checkNotNull(sharedBuildCacheDir.listFiles())) {
// First, make sure the path refers to a private cache directory
if (!buildCacheDir.isDirectory()) {
continue;
}
GradleVersion pluginVersion =
GradleVersion.tryParseAndroidGradlePluginVersion(buildCacheDir.getName());
if (pluginVersion == null) {
continue;
}
// Then, make sure we only delete private cache directories created by *previous*
// plugin versions
if (pluginVersion.compareTo(currentPluginVersion) >= 0) {
continue;
}
// Find out the last time this cache was used
File markerFile = new File(buildCacheDir, CACHE_USE_MARKER_FILE_NAME);
long lastUsedTimestamp = markerFile.lastModified();
if (lastUsedTimestamp == 0) {
// The marker file does not yet exist, use the directory's timestamp instead
lastUsedTimestamp = buildCacheDir.lastModified();
}
// Finally, delete the private cache directory if it has not been used in a while
if (lastUsedTimestamp != 0
&& Duration.ofMillis(System.currentTimeMillis() - lastUsedTimestamp)
.compareTo(cacheDirectoryLifeTime)
>= 0) {
// There could be a race condition here if another thread/process also reaches this
// point, but that is Okay because the method call below is thread-safe and
// process-safe, it's just that the code below will be executed more than once
// (which is fine).
try {
FileCache.getInstanceWithMultiProcessLocking(buildCacheDir).delete();
// Also delete the lock file. Note that it is generally not safe to delete lock
// files if some other thread/process might be using the cache, but since this
// cache (created and used only by an older plugin version) has not been used in
// a while, it is unlikely that it is suddenly being used now.
FileUtils.deleteIfExists(SynchronizedFile.getLockFile(buildCacheDir));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
}
/**
* Updates the timestamp of the given marker file to the current system timestamp, creating it
* first if it does not yet exist.
*/
@SuppressWarnings("ResultOfMethodCallIgnored")
private static void updateMarkerFile(@NonNull File markerFile) {
FileUtils.mkdirs(markerFile.getParentFile());
try {
markerFile.createNewFile();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
markerFile.setLastModified(System.currentTimeMillis());
}
}