Skip to content

Commit 6858c18

Browse files
authored
Reimplement NPM caching to fix concurrency (#2151)
2 parents d95c2eb + 94e22de commit 6858c18

File tree

5 files changed

+50
-66
lines changed

5 files changed

+50
-66
lines changed

CHANGES.md

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1818
* Correctly provide EditorConfig property types for Ktlint ([#2052](https://github.com/diffplug/spotless/issues/2052))
1919
* 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))
2020
* scalafmt.conf fileOverride section now works correctly ([#1854](https://github.com/diffplug/spotless/pull/1854))
21+
* Reworked ShadowCopy (`npmInstallCache`) to use atomic filesystem operations, resolving several race conditions that could arise ([#2151](https://github.com/diffplug/spotless/pull/2151))
2122
### Changes
2223
* Bump default `cleanthat` version to latest `2.16` -> `2.20`. ([#1725](https://github.com/diffplug/spotless/pull/1725))
2324
* 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

+47-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;
@@ -55,70 +56,66 @@ private File shadowCopyRoot() {
5556
}
5657

5758
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);
59+
File target = entry(key, orig.getName());
60+
if (target.exists()) {
61+
logger.debug("Shadow copy entry already exists, not overwriting: {}", key);
62+
} else {
6163
try {
62-
NpmResourceHelper.awaitFileDeleted(markerFilePath(key).toFile(), Duration.ofSeconds(120));
63-
} catch (TimeoutException e) {
64-
throw new RuntimeException(e);
64+
storeEntry(key, orig, target);
65+
} catch (Throwable ex) {
66+
// Log but don't fail
67+
logger.warn("Unable to store cache entry for {}", key, ex);
6568
}
6669
}
70+
}
71+
72+
private void storeEntry(String key, File orig, File target) throws IOException {
73+
// Create a temp directory in the same directory as target
74+
Files.createDirectories(target.toPath().getParent());
75+
Path tempDirectory = Files.createTempDirectory(target.toPath().getParent(), key);
76+
logger.debug("Will store entry {} to temporary directory {}, which is a sibling of the ultimate target {}", orig, tempDirectory, target);
77+
6778
try {
68-
storeEntry(key, orig);
79+
// Copy orig to temp dir
80+
Files.walkFileTree(orig.toPath(), new CopyDirectoryRecursively(tempDirectory, orig.toPath()));
81+
try {
82+
logger.debug("Finished storing entry {}. Atomically moving temporary directory {} into final place {}", key, tempDirectory, target);
83+
// Atomically rename the completed cache entry into place
84+
Files.move(tempDirectory, target.toPath(), StandardCopyOption.ATOMIC_MOVE);
85+
} catch (FileAlreadyExistsException | DirectoryNotEmptyException e) {
86+
// Someone already beat us to it
87+
logger.debug("Shadow copy entry now exists, not overwriting: {}", key);
88+
} catch (AtomicMoveNotSupportedException e) {
89+
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);
90+
}
6991
} finally {
70-
cleanupReservation(key);
92+
// Best effort to clean up
93+
if (Files.exists(tempDirectory)) {
94+
try {
95+
Files.walkFileTree(tempDirectory, new DeleteDirectoryRecursively());
96+
} catch (Throwable ex) {
97+
logger.warn("Ignoring error while cleaning up temporary copy", ex);
98+
}
99+
}
71100
}
72101
}
73102

74103
public File getEntry(String key, String fileName) {
75104
return entry(key, fileName);
76105
}
77106

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-
98107
private File entry(String key, String origName) {
99108
return Paths.get(shadowCopyRoot().getAbsolutePath(), key, origName).toFile();
100109
}
101110

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-
114111
public File copyEntryInto(String key, String origName, File targetParentFolder) {
115112
File target = Paths.get(targetParentFolder.getAbsolutePath(), origName).toFile();
116113
if (target.exists()) {
117114
logger.warn("Shadow copy destination already exists, deleting! {}: {}", key, target);
118115
ThrowingEx.run(() -> Files.walkFileTree(target.toPath(), new DeleteDirectoryRecursively()));
119116
}
120117
// 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))));
118+
ThrowingEx.run(() -> Files.walkFileTree(entry(key, origName).toPath(), new CopyDirectoryRecursively(target.toPath(), entry(key, origName).toPath())));
122119
return target;
123120
}
124121

@@ -127,20 +124,20 @@ public boolean entryExists(String key, String origName) {
127124
}
128125

129126
private static class CopyDirectoryRecursively extends SimpleFileVisitor<Path> {
130-
private final File target;
131-
private final File orig;
127+
private final Path target;
128+
private final Path orig;
132129

133130
private boolean tryHardLink = true;
134131

135-
public CopyDirectoryRecursively(File target, File orig) {
132+
public CopyDirectoryRecursively(Path target, Path orig) {
136133
this.target = target;
137134
this.orig = orig;
138135
}
139136

140137
@Override
141138
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
142139
// create directory on target
143-
Files.createDirectories(target.toPath().resolve(orig.toPath().relativize(dir)));
140+
Files.createDirectories(target.resolve(orig.relativize(dir)));
144141
return super.preVisitDirectory(dir, attrs);
145142
}
146143

@@ -149,7 +146,7 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IO
149146
// first try to hardlink, if that fails, copy
150147
if (tryHardLink) {
151148
try {
152-
Files.createLink(target.toPath().resolve(orig.toPath().relativize(file)), file);
149+
Files.createLink(target.resolve(orig.relativize(file)), file);
153150
return super.visitFile(file, attrs);
154151
} catch (UnsupportedOperationException | SecurityException | FileSystemException e) {
155152
logger.debug("Shadow copy entry does not support hard links: {}. Switching to 'copy'.", file, e);
@@ -160,11 +157,12 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IO
160157
}
161158
}
162159
// copy file to target
163-
Files.copy(file, target.toPath().resolve(orig.toPath().relativize(file)));
160+
Files.copy(file, target.resolve(orig.relativize(file)));
164161
return super.visitFile(file, attrs);
165162
}
166163
}
167164

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

plugin-gradle/CHANGES.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1515
* Fixed memory leak introduced in 6.21.0 ([#2067](https://github.com/diffplug/spotless/issues/2067))
1616
* 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))
1717
* scalafmt.conf fileOverride section now works correctly ([#1854](https://github.com/diffplug/spotless/pull/1854))
18+
* Reworked ShadowCopy (`npmInstallCache`) to use atomic filesystem operations, resolving several race conditions that could arise ([#2151](https://github.com/diffplug/spotless/pull/2151))
1819
### Changes
1920
* Bump default `cleanthat` version to latest `2.16` -> `2.20`. ([#1725](https://github.com/diffplug/spotless/pull/1725))
2021
* 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
@@ -11,6 +11,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1111
* Correctly provide EditorConfig property types for Ktlint ([#2052](https://github.com/diffplug/spotless/issues/2052))
1212
* 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))
1313
* scalafmt.conf fileOverride section now works correctly ([#1854](https://github.com/diffplug/spotless/pull/1854))
14+
* Reworked ShadowCopy (`npmInstallCache`) to use atomic filesystem operations, resolving several race conditions that could arise ([#2151](https://github.com/diffplug/spotless/pull/2151))
1415
### Changes
1516
* Bump default `cleanthat` version to latest `2.16` -> `2.20`. ([#1725](https://github.com/diffplug/spotless/pull/1725))
1617
* 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)