Skip to content

Commit fe1c64a

Browse files
authored
Reimplement NPM caching to fix concurrency (#2151 tweaked by #2155)
2 parents 3308d33 + abf887d commit fe1c64a

File tree

5 files changed

+53
-66
lines changed

5 files changed

+53
-66
lines changed

CHANGES.md

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1919
* Correctly provide EditorConfig property types for Ktlint ([#2052](https://github.com/diffplug/spotless/issues/2052))
2020
* Made ShadowCopy (`npmInstallCache`) more robust by re-creating the cache dir if it goes missing ([#1984](https://github.com/diffplug/spotless/issues/1984),[2096](https://github.com/diffplug/spotless/pull/2096))
2121
* scalafmt.conf fileOverride section now works correctly ([#1854](https://github.com/diffplug/spotless/pull/1854))
22+
* Reworked ShadowCopy (`npmInstallCache`) to use atomic filesystem operations, resolving several race conditions that could arise ([#2151](https://github.com/diffplug/spotless/pull/2151))
2223
### Changes
2324
* Bump default `cleanthat` version to latest `2.16` -> `2.20`. ([#1725](https://github.com/diffplug/spotless/pull/1725))
2425
* Bump default `gherkin-utils` version to latest `8.0.2` -> `9.0.0`. ([#1703](https://github.com/diffplug/spotless/pull/1703))

lib/src/main/java/com/diffplug/spotless/npm/ShadowCopy.java

+50-49
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@
1717

1818
import java.io.File;
1919
import java.io.IOException;
20+
import java.nio.file.AtomicMoveNotSupportedException;
21+
import java.nio.file.DirectoryNotEmptyException;
2022
import java.nio.file.FileAlreadyExistsException;
2123
import java.nio.file.FileSystemException;
2224
import java.nio.file.FileVisitResult;
2325
import java.nio.file.Files;
2426
import java.nio.file.Path;
2527
import java.nio.file.Paths;
2628
import java.nio.file.SimpleFileVisitor;
29+
import java.nio.file.StandardCopyOption;
2730
import java.nio.file.attribute.BasicFileAttributes;
28-
import java.time.Duration;
29-
import java.util.concurrent.TimeoutException;
3031
import java.util.function.Supplier;
3132

3233
import javax.annotation.Nonnull;
@@ -36,6 +37,8 @@
3637

3738
import com.diffplug.spotless.ThrowingEx;
3839

40+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
41+
3942
class ShadowCopy {
4043

4144
private static final Logger logger = LoggerFactory.getLogger(ShadowCopy.class);
@@ -55,70 +58,67 @@ private File shadowCopyRoot() {
5558
}
5659

5760
public void addEntry(String key, File orig) {
58-
// prevent concurrent adding of entry with same key
59-
if (!reserveSubFolder(key)) {
60-
logger.debug("Shadow copy entry already in progress: {}. Awaiting finalization.", key);
61+
File target = entry(key, orig.getName());
62+
if (target.exists()) {
63+
logger.debug("Shadow copy entry already exists, not overwriting: {}", key);
64+
} else {
6165
try {
62-
NpmResourceHelper.awaitFileDeleted(markerFilePath(key).toFile(), Duration.ofSeconds(120));
63-
} catch (TimeoutException e) {
64-
throw new RuntimeException(e);
66+
storeEntry(key, orig, target);
67+
} catch (Throwable ex) {
68+
// Log but don't fail
69+
logger.warn("Unable to store cache entry for {}", key, ex);
6570
}
6671
}
72+
}
73+
74+
@SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
75+
private void storeEntry(String key, File orig, File target) throws IOException {
76+
// Create a temp directory in the same directory as target
77+
Files.createDirectories(target.toPath().getParent());
78+
Path tempDirectory = Files.createTempDirectory(target.toPath().getParent(), key);
79+
logger.debug("Will store entry {} to temporary directory {}, which is a sibling of the ultimate target {}", orig, tempDirectory, target);
80+
6781
try {
68-
storeEntry(key, orig);
82+
// Copy orig to temp dir
83+
Files.walkFileTree(orig.toPath(), new CopyDirectoryRecursively(tempDirectory, orig.toPath()));
84+
try {
85+
logger.debug("Finished storing entry {}. Atomically moving temporary directory {} into final place {}", key, tempDirectory, target);
86+
// Atomically rename the completed cache entry into place
87+
Files.move(tempDirectory, target.toPath(), StandardCopyOption.ATOMIC_MOVE);
88+
} catch (FileAlreadyExistsException | DirectoryNotEmptyException e) {
89+
// Someone already beat us to it
90+
logger.debug("Shadow copy entry now exists, not overwriting: {}", key);
91+
} catch (AtomicMoveNotSupportedException e) {
92+
logger.warn("The filesystem at {} does not support atomic moves. Spotless cannot safely cache on such a system due to race conditions. Caching has been skipped.", target.toPath().getParent(), e);
93+
}
6994
} finally {
70-
cleanupReservation(key);
95+
// Best effort to clean up
96+
if (Files.exists(tempDirectory)) {
97+
try {
98+
Files.walkFileTree(tempDirectory, new DeleteDirectoryRecursively());
99+
} catch (Throwable ex) {
100+
logger.warn("Ignoring error while cleaning up temporary copy", ex);
101+
}
102+
}
71103
}
72104
}
73105

74106
public File getEntry(String key, String fileName) {
75107
return entry(key, fileName);
76108
}
77109

78-
private void storeEntry(String key, File orig) {
79-
File target = entry(key, orig.getName());
80-
if (target.exists()) {
81-
logger.debug("Shadow copy entry already exists: {}", key);
82-
// delete directory "target" recursively
83-
// https://stackoverflow.com/questions/3775694/deleting-folder-from-java
84-
ThrowingEx.run(() -> Files.walkFileTree(target.toPath(), new DeleteDirectoryRecursively()));
85-
}
86-
// copy directory "orig" to "target" using hard links if possible or a plain copy otherwise
87-
ThrowingEx.run(() -> Files.walkFileTree(orig.toPath(), new CopyDirectoryRecursively(target, orig)));
88-
}
89-
90-
private void cleanupReservation(String key) {
91-
ThrowingEx.run(() -> Files.delete(markerFilePath(key)));
92-
}
93-
94-
private Path markerFilePath(String key) {
95-
return Paths.get(shadowCopyRoot().getAbsolutePath(), key + ".marker");
96-
}
97-
98110
private File entry(String key, String origName) {
99111
return Paths.get(shadowCopyRoot().getAbsolutePath(), key, origName).toFile();
100112
}
101113

102-
private boolean reserveSubFolder(String key) {
103-
// put a marker file named "key".marker in "shadowCopyRoot" to make sure no other process is using it or return false if it already exists
104-
try {
105-
Files.createFile(Paths.get(shadowCopyRoot().getAbsolutePath(), key + ".marker"));
106-
return true;
107-
} catch (FileAlreadyExistsException e) {
108-
return false;
109-
} catch (IOException e) {
110-
throw new RuntimeException(e);
111-
}
112-
}
113-
114114
public File copyEntryInto(String key, String origName, File targetParentFolder) {
115115
File target = Paths.get(targetParentFolder.getAbsolutePath(), origName).toFile();
116116
if (target.exists()) {
117117
logger.warn("Shadow copy destination already exists, deleting! {}: {}", key, target);
118118
ThrowingEx.run(() -> Files.walkFileTree(target.toPath(), new DeleteDirectoryRecursively()));
119119
}
120120
// copy directory "orig" to "target" using hard links if possible or a plain copy otherwise
121-
ThrowingEx.run(() -> Files.walkFileTree(entry(key, origName).toPath(), new CopyDirectoryRecursively(target, entry(key, origName))));
121+
ThrowingEx.run(() -> Files.walkFileTree(entry(key, origName).toPath(), new CopyDirectoryRecursively(target.toPath(), entry(key, origName).toPath())));
122122
return target;
123123
}
124124

@@ -127,20 +127,20 @@ public boolean entryExists(String key, String origName) {
127127
}
128128

129129
private static class CopyDirectoryRecursively extends SimpleFileVisitor<Path> {
130-
private final File target;
131-
private final File orig;
130+
private final Path target;
131+
private final Path orig;
132132

133133
private boolean tryHardLink = true;
134134

135-
public CopyDirectoryRecursively(File target, File orig) {
135+
public CopyDirectoryRecursively(Path target, Path orig) {
136136
this.target = target;
137137
this.orig = orig;
138138
}
139139

140140
@Override
141141
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
142142
// create directory on target
143-
Files.createDirectories(target.toPath().resolve(orig.toPath().relativize(dir)));
143+
Files.createDirectories(target.resolve(orig.relativize(dir)));
144144
return super.preVisitDirectory(dir, attrs);
145145
}
146146

@@ -149,7 +149,7 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IO
149149
// first try to hardlink, if that fails, copy
150150
if (tryHardLink) {
151151
try {
152-
Files.createLink(target.toPath().resolve(orig.toPath().relativize(file)), file);
152+
Files.createLink(target.resolve(orig.relativize(file)), file);
153153
return super.visitFile(file, attrs);
154154
} catch (UnsupportedOperationException | SecurityException | FileSystemException e) {
155155
logger.debug("Shadow copy entry does not support hard links: {}. Switching to 'copy'.", file, e);
@@ -160,11 +160,12 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IO
160160
}
161161
}
162162
// copy file to target
163-
Files.copy(file, target.toPath().resolve(orig.toPath().relativize(file)));
163+
Files.copy(file, target.resolve(orig.relativize(file)));
164164
return super.visitFile(file, attrs);
165165
}
166166
}
167167

168+
// https://stackoverflow.com/questions/3775694/deleting-folder-from-java
168169
private static class DeleteDirectoryRecursively extends SimpleFileVisitor<Path> {
169170
@Override
170171
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {

plugin-gradle/CHANGES.md

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1616
* Fixed memory leak introduced in 6.21.0 ([#2067](https://github.com/diffplug/spotless/issues/2067))
1717
* Made ShadowCopy (`npmInstallCache`) more robust by re-creating the cache dir if it goes missing ([#1984](https://github.com/diffplug/spotless/issues/1984),[2096](https://github.com/diffplug/spotless/pull/2096))
1818
* scalafmt.conf fileOverride section now works correctly ([#1854](https://github.com/diffplug/spotless/pull/1854))
19+
* Reworked ShadowCopy (`npmInstallCache`) to use atomic filesystem operations, resolving several race conditions that could arise ([#2151](https://github.com/diffplug/spotless/pull/2151))
1920
### Changes
2021
* Bump default `cleanthat` version to latest `2.16` -> `2.20`. ([#1725](https://github.com/diffplug/spotless/pull/1725))
2122
* Bump default `gherkin-utils` version to latest `8.0.2` -> `9.0.0`. ([#1703](https://github.com/diffplug/spotless/pull/1703))

plugin-maven/CHANGES.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1313
* Correctly provide EditorConfig property types for Ktlint ([#2052](https://github.com/diffplug/spotless/issues/2052))
1414
* Made ShadowCopy (`npmInstallCache`) more robust by re-creating the cache dir if it goes missing ([#1984](https://github.com/diffplug/spotless/issues/1984),[2096](https://github.com/diffplug/spotless/pull/2096))
1515
* scalafmt.conf fileOverride section now works correctly ([#1854](https://github.com/diffplug/spotless/pull/1854))
16+
* Reworked ShadowCopy (`npmInstallCache`) to use atomic filesystem operations, resolving several race conditions that could arise ([#2151](https://github.com/diffplug/spotless/pull/2151))
1617
### Changes
1718
* Bump default `cleanthat` version to latest `2.16` -> `2.20`. ([#1725](https://github.com/diffplug/spotless/pull/1725))
1819
* Bump default `gherkin-utils` version to latest `8.0.2` -> `9.0.0`. ([#1703](https://github.com/diffplug/spotless/pull/1703))

testlib/src/test/java/com/diffplug/spotless/npm/ShadowCopyTest.java

-17
Original file line numberDiff line numberDiff line change
@@ -96,23 +96,6 @@ void changingAFolderAfterAddingItDoesNotChangeTheShadowCopy() throws IOException
9696
Assertions.assertThat(shadowCopy.listFiles()[0].getName()).isNotEqualTo(folderWithRandomFile.listFiles()[0].getName());
9797
}
9898

99-
@Test
100-
void addingTheSameEntryTwiceResultsInSecondEntryBeingRetained() throws IOException {
101-
File folderWithRandomFile = newFolderWithRandomFile();
102-
shadowCopy.addEntry("someEntry", folderWithRandomFile);
103-
104-
// now change the orig
105-
Files.delete(folderWithRandomFile.listFiles()[0].toPath());
106-
File newRandomFile = new File(folderWithRandomFile, "replacedFile.txt");
107-
writeRandomStringOfLengthToFile(newRandomFile, 100);
108-
109-
// and then add the same entry with new content again and check that they now are the same again
110-
shadowCopy.addEntry("someEntry", folderWithRandomFile);
111-
File shadowCopyFile = shadowCopy.getEntry("someEntry", folderWithRandomFile.getName());
112-
Assertions.assertThat(shadowCopyFile.listFiles()).hasSize(folderWithRandomFile.listFiles().length);
113-
assertAllFilesAreEqualButNotSameAbsolutePath(folderWithRandomFile, shadowCopyFile);
114-
}
115-
11699
@Test
117100
void aFolderCanBeCopiedUsingShadowCopy() throws IOException {
118101
File folderWithRandomFile = newFolderWithRandomFile();

0 commit comments

Comments
 (0)