Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import com.google.common.hash.Hasher;
import com.google.common.io.BaseEncoding;
import com.google.devtools.build.lib.vfs.DigestHashFunction;
import com.google.devtools.build.lib.vfs.FileAccessException;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import com.google.devtools.build.lib.vfs.Path;
import java.io.IOException;
Expand Down Expand Up @@ -242,19 +241,7 @@ private void storeCacheValue(
Path tmpName = cacheEntry.getRelative(TMP_PREFIX + UUID.randomUUID());
cacheEntry.createDirectoryAndParents();
fileWriter.writeTo(tmpName);
try {
tmpName.renameTo(cacheValue);
} catch (FileAccessException e) {
// On Windows, atomically replacing a file that is currently opened (e.g. due to a concurrent
// get on the cache) results in renameTo throwing this exception, which wraps an
// AccessDeniedException. This case is benign since if the target path already exists, we know
// that another thread won the race to place the file in the cache. As the exception is rather
// generic and could result from other failure types, we rethrow the exception if the cache
// entry hasn't been created.
if (!cacheValue.exists()) {
throw e;
}
}
FileSystemUtils.renameToleratingConcurrentCreation(tmpName, cacheValue);

if (!Strings.isNullOrEmpty(canonicalId)) {
String idHash = keyType.newHasher().putBytes(canonicalId.getBytes(UTF_8)).hash().toString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import com.google.devtools.build.lib.remote.util.DigestOutputStream;
import com.google.devtools.build.lib.remote.util.DigestUtil;
import com.google.devtools.build.lib.remote.util.Utils;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import com.google.devtools.build.lib.vfs.Path;
import com.google.protobuf.ByteString;
import com.google.protobuf.ExtensionRegistryLite;
Expand Down Expand Up @@ -142,7 +143,7 @@ public void captureFile(Path src, Digest digest, Store store) throws IOException
}

target.getParentDirectory().createDirectoryAndParents();
src.renameTo(target);
FileSystemUtils.renameToleratingConcurrentCreation(src, target);
}

private ListenableFuture<Void> download(Digest digest, OutputStream out, Store store) {
Expand Down Expand Up @@ -333,7 +334,7 @@ public void saveFile(Digest digest, Store store, InputStream in) throws IOExcept
}
}
path.getParentDirectory().createDirectoryAndParents();
temp.renameTo(path);
FileSystemUtils.renameToleratingConcurrentCreation(temp, path);
} catch (IOException e) {
try {
temp.delete();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.google.common.io.ByteStreams;
import com.google.devtools.build.lib.concurrent.ThreadSafety.ConditionallyThreadSafe;
import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe;
import com.google.devtools.build.lib.util.OS;
import com.google.devtools.build.lib.util.StringEncoding;
import java.io.FileNotFoundException;
import java.io.IOException;
Expand Down Expand Up @@ -489,6 +490,40 @@ public static MoveResult moveFile(Path from, Path to) throws IOException {
}
}

/**
* Atomically renames a source file to a target file, tolerating the case where another thread has
* concurrently created the target file (e.g. because it is known to have the same content in a
* CAS-like structure).
*
* <p>This handles a Windows-specific edge case: when the target file is being read by another
* process (e.g., during a concurrent cache lookup), the rename operation fails with a {@link
* FileAccessException}. If the target file already exists when this happens, it means another
* thread won the race to create it, so we can safely delete the source file.
*
* <p>The parent directories of the target file must already exist.
*
* @param source the file to rename
* @param target the destination path
*/
@ThreadSafe
public static void renameToleratingConcurrentCreation(Path source, Path target)
throws IOException {
try {
source.renameTo(target);
} catch (FileAccessException e) {
// On Windows, atomically replacing a file that is currently opened (e.g. due to a concurrent
// get on the cache) results in renameTo throwing this exception, which wraps an
// AccessDeniedException. This case is benign since if the target path already exists, we know
// that another thread won the race to place the file in the cache. As the exception is rather
// generic and could result from other failure types, we rethrow the exception if the cache
// entry hasn't been created.
if (OS.getCurrent() != OS.WINDOWS || !target.exists()) {
throw e;
}
source.delete();
}
}

/* Directory tree operations. */

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ java_test(
"//src/test/java/com/google/devtools/build/lib:test_runner",
"//src/test/java/com/google/devtools/build/lib/testutil:TestUtils",
"//src/test/java/com/google/devtools/build/lib/testutil:external_file_system_lock",
"//src/test/java/com/google/devtools/build/lib/vfs/util:util_internal",
"//third_party:error_prone_annotations",
"//third_party:guava",
"//third_party:junit4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,25 @@
import com.google.devtools.build.lib.remote.common.OutputDigestMismatchException;
import com.google.devtools.build.lib.remote.common.RemoteCacheClient.ActionKey;
import com.google.devtools.build.lib.remote.util.DigestUtil;
import com.google.devtools.build.lib.testutil.TestUtils;
import com.google.devtools.build.lib.vfs.DigestHashFunction;
import com.google.devtools.build.lib.vfs.FileSystem;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.build.lib.vfs.SyscallCache;
import com.google.devtools.build.lib.vfs.bazel.BazelHashFunctions;
import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
import com.google.devtools.build.lib.vfs.util.FileSystems;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.protobuf.ByteString;
import com.google.protobuf.Message;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
Expand Down Expand Up @@ -346,6 +354,86 @@ public void downloadActionResult_withReferencedTreeFileMissing_returnsNull() thr
assertThat(result).isNull();
}

@Test
public void concurrentUploadDownload()
throws IOException, ExecutionException, InterruptedException {
var nativeDiskCacheDir = TestUtils.createUniqueTmpDir(FileSystems.getNativeFileSystem());
var nativeClient =
new DiskCacheClient(nativeDiskCacheDir, DIGEST_UTIL, /* verifyDownloads= */ false);
var tasks = new ArrayList<Future<?>>();
// Use 1 MB blobs to increase the window for concurrent access during write/rename.
var contentSize = 1024 * 1024;
var numConcurrentOps = 10;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int attempt = 0; attempt < 100; attempt++) {
var contentArray = new byte[contentSize];
// Fill with a pattern based on the attempt number.
for (int i = 0; i < contentSize; i++) {
contentArray[i] = (byte) (attempt + i);
}
var contentBytes = ByteString.copyFrom(contentArray);
var contentDigest = DIGEST_UTIL.compute(contentArray);
// Use a latch to ensure all concurrent tasks start at roughly the same time.
var startLatch = new CountDownLatch(numConcurrentOps);
// Half the tasks do uploads, half do downloads with a slow OutputStream to keep the file
// open longer. This maximizes the chance of a rename failing because a download has the
// file open.
for (int concurrentOp = 0; concurrentOp < numConcurrentOps; concurrentOp++) {
boolean isUploader = concurrentOp % 2 == 0;
tasks.add(
executor.submit(
() -> {
// Signal ready and wait for all tasks to be ready.
startLatch.countDown();
startLatch.await();
if (isUploader) {
getFromFuture(nativeClient.uploadBlob(contentDigest, contentBytes));
} else {
// Use a slow OutputStream that pauses periodically to keep the file open
// longer during download.
var out =
new OutputStream() {
private int bytesWritten = 0;

@Override
public void write(int b) throws IOException {
bytesWritten++;
maybeSleep();
}

@Override
public void write(byte[] b, int off, int len) throws IOException {
bytesWritten += len;
maybeSleep();
}

private void maybeSleep() {
// Sleep every 64KB to slow down the download.
if (bytesWritten % (64 * 1024) < 100) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
};
try {
getFromFuture(nativeClient.downloadBlob(contentDigest, out));
} catch (CacheNotFoundException ignored) {
// File not yet uploaded by another task.
}
}
return null;
}));
}
}
for (var task : tasks) {
task.get();
}
}
}

private Tree getTreeWithFile(Digest fileDigest) {
return Tree.newBuilder()
.addChildren(Directory.newBuilder().addFiles(FileNode.newBuilder().setDigest(fileDigest)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import static com.google.devtools.build.lib.vfs.FileSystemUtils.moveFile;
import static com.google.devtools.build.lib.vfs.FileSystemUtils.relativePath;
import static com.google.devtools.build.lib.vfs.FileSystemUtils.removeExtension;
import static com.google.devtools.build.lib.vfs.FileSystemUtils.renameToleratingConcurrentCreation;
import static com.google.devtools.build.lib.vfs.FileSystemUtils.touchFile;
import static com.google.devtools.build.lib.vfs.FileSystemUtils.traverseTree;
import static java.nio.charset.StandardCharsets.ISO_8859_1;
Expand Down Expand Up @@ -1016,6 +1017,97 @@ public void testCreateHardLinkForNonEmptyDirectory_success() throws Exception {
.isEqualTo(fileSystem.stat(originalPath3.asFragment(), false).getNodeId());
}

@Test
public void testRenameToleratingConcurrentCreation_success() throws Exception {
Path source = fileSystem.getPath("/source");
Path target = fileSystem.getPath("/target");
target.getParentDirectory().createDirectoryAndParents();

FileSystemUtils.writeContent(source, UTF_8, "hello world");

renameToleratingConcurrentCreation(source, target);

assertThat(source.exists()).isFalse();
assertThat(target.exists()).isTrue();
assertThat(FileSystemUtils.readContent(target, UTF_8)).isEqualTo("hello world");
}

@Test
public void testRenameToleratingConcurrentCreation_sourceDoesNotExist() throws Exception {
Path source = fileSystem.getPath("/source");
Path target = fileSystem.getPath("/target");
target.getParentDirectory().createDirectoryAndParents();

assertThrows(
FileNotFoundException.class, () -> renameToleratingConcurrentCreation(source, target));
}

@Test
public void testRenameToleratingConcurrentCreation_targetParentDoesNotExist() throws Exception {
Path source = fileSystem.getPath("/source");
Path target = fileSystem.getPath("/nonexistent/target");

FileSystemUtils.writeContent(source, UTF_8, "hello world");

assertThrows(
FileNotFoundException.class, () -> renameToleratingConcurrentCreation(source, target));
}

@Test
public void testRenameToleratingConcurrentCreation_toleratesFileAccessExceptionOnWindows()
throws Exception {
FileSystem fs = new FileAccessExceptionOnRenameFS();
Path source = fs.getPath("/source");
Path target = fs.getPath("/target");
source.getParentDirectory().createDirectoryAndParents();
target.getParentDirectory().createDirectoryAndParents();

FileSystemUtils.writeContent(source, UTF_8, "hello world");
FileSystemUtils.writeContent(target, UTF_8, "existing content");

if (OS.getCurrent() == OS.WINDOWS) {
renameToleratingConcurrentCreation(source, target);

assertThat(source.exists()).isFalse();
assertThat(target.exists()).isTrue();
assertThat(FileSystemUtils.readContent(target, UTF_8)).isEqualTo("existing content");
} else {
assertThrows(
FileAccessException.class, () -> renameToleratingConcurrentCreation(source, target));

assertThat(source.exists()).isTrue();
}
}

@Test
public void testRenameToleratingConcurrentCreation_throwsIfTargetDoesNotExistAfterException()
throws Exception {
FileSystem fs = new FileAccessExceptionOnRenameFS();
Path source = fs.getPath("/source");
Path target = fs.getPath("/target");
source.getParentDirectory().createDirectoryAndParents();
target.getParentDirectory().createDirectoryAndParents();

FileSystemUtils.writeContent(source, UTF_8, "hello world");

assertThrows(
FileAccessException.class, () -> renameToleratingConcurrentCreation(source, target));

assertThat(source.exists()).isTrue();
}

/** A file system that throws FileAccessException on rename, simulating Windows behavior. */
static class FileAccessExceptionOnRenameFS extends InMemoryFileSystem {
FileAccessExceptionOnRenameFS() {
super(DigestHashFunction.SHA256);
}

@Override
public void renameTo(PathFragment source, PathFragment target) throws IOException {
throw new FileAccessException("Access denied (simulated Windows behavior)");
}
}

static class MultipleDeviceFS extends InMemoryFileSystem {
MultipleDeviceFS() {
super(DigestHashFunction.SHA256);
Expand Down