Skip to content

Commit 84da9c5

Browse files
authored
Merge pull request #3 from mawinter69/JENKINS-66898
[fix JENKINS-66898] make the cache thread safe
2 parents 2b196c7 + 8f4fde4 commit 84da9c5

File tree

7 files changed

+175
-38
lines changed

7 files changed

+175
-38
lines changed

src/main/java/org/jenkinsci/plugins/workflow/libs/LibraryAdder.java

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@
4343
import java.util.LinkedHashMap;
4444
import java.util.List;
4545
import java.util.Map;
46-
import java.util.Set;
4746
import java.util.TreeMap;
47+
import java.util.concurrent.ConcurrentHashMap;
48+
import java.util.concurrent.locks.ReentrantReadWriteLock;
4849
import java.util.logging.Level;
4950
import java.util.logging.Logger;
5051
import edu.umd.cs.findbugs.annotations.CheckForNull;
@@ -65,7 +66,13 @@
6566
@Extension public class LibraryAdder extends ClasspathAdder {
6667

6768
private static final Logger LOGGER = Logger.getLogger(LibraryAdder.class.getName());
69+
70+
private static ConcurrentHashMap<String, ReentrantReadWriteLock> cacheRetrieveLock = new ConcurrentHashMap<>();
6871

72+
static @NonNull ReentrantReadWriteLock getReadWriteLockFor(@NonNull String name) {
73+
return cacheRetrieveLock.computeIfAbsent(name, s -> new ReentrantReadWriteLock(true));
74+
}
75+
6976
@Override public List<Addition> add(CpsFlowExecution execution, List<String> libraries, HashMap<String, Boolean> changelogs) throws Exception {
7077
Queue.Executable executable = execution.getOwner().getExecutable();
7178
Run<?,?> build;
@@ -156,6 +163,36 @@
156163
}
157164
}
158165

166+
private enum CacheStatus {
167+
VALID,
168+
DOES_NOT_EXIST,
169+
EXPIRED;
170+
}
171+
172+
private static CacheStatus getCacheStatus(@NonNull LibraryCachingConfiguration cachingConfiguration, @NonNull final FilePath versionCacheDir)
173+
throws IOException, InterruptedException
174+
{
175+
if (cachingConfiguration.isRefreshEnabled()) {
176+
final long cachingMilliseconds = cachingConfiguration.getRefreshTimeMilliseconds();
177+
178+
if(versionCacheDir.exists()) {
179+
if ((versionCacheDir.lastModified() + cachingMilliseconds) > System.currentTimeMillis()) {
180+
return CacheStatus.VALID;
181+
} else {
182+
return CacheStatus.EXPIRED;
183+
}
184+
} else {
185+
return CacheStatus.DOES_NOT_EXIST;
186+
}
187+
} else {
188+
if (versionCacheDir.exists()) {
189+
return CacheStatus.VALID;
190+
} else {
191+
return CacheStatus.DOES_NOT_EXIST;
192+
}
193+
}
194+
}
195+
159196
/** Retrieve library files. */
160197
static List<URL> retrieve(@NonNull LibraryRecord record, @NonNull LibraryRetriever retriever, @NonNull TaskListener listener, @NonNull Run<?,?> run, @NonNull CpsFlowExecution execution) throws Exception {
161198
String name = record.name;
@@ -165,42 +202,60 @@ static List<URL> retrieve(@NonNull LibraryRecord record, @NonNull LibraryRetriev
165202
FilePath libDir = new FilePath(execution.getOwner().getRootDir()).child("libs/" + record.getDirectoryName());
166203
Boolean shouldCache = cachingConfiguration != null;
167204
final FilePath versionCacheDir = new FilePath(LibraryCachingConfiguration.getGlobalLibrariesCacheDir(), record.getDirectoryName());
168-
final FilePath retrieveLockFile = new FilePath(versionCacheDir, LibraryCachingConfiguration.RETRIEVE_LOCK_FILE);
205+
ReentrantReadWriteLock retrieveLock = getReadWriteLockFor(record.getDirectoryName());
169206
final FilePath lastReadFile = new FilePath(versionCacheDir, LibraryCachingConfiguration.LAST_READ_FILE);
170207

171208
if(shouldCache && cachingConfiguration.isExcluded(version)) {
172209
listener.getLogger().println("Library " + name + "@" + version + " is excluded from caching.");
173210
shouldCache = false;
174211
}
175212

176-
if(shouldCache && retrieveLockFile.exists()) {
177-
listener.getLogger().println("Library " + name + "@" + version + " is currently being cached by another job, retrieving without cache.");
178-
shouldCache = false;
179-
}
180-
181213
if(shouldCache) {
182-
if (cachingConfiguration.isRefreshEnabled()) {
183-
final long cachingMinutes = cachingConfiguration.getRefreshTimeMinutes();
184-
final long cachingMilliseconds = cachingConfiguration.getRefreshTimeMilliseconds();
185-
186-
if(versionCacheDir.exists() && (versionCacheDir.lastModified() + cachingMilliseconds) < System.currentTimeMillis()) {
187-
listener.getLogger().println("Library " + name + "@" + version + " is due for a refresh after " + cachingMinutes + " minutes, clearing.");
188-
versionCacheDir.deleteRecursive();
214+
retrieveLock.readLock().lockInterruptibly();
215+
try {
216+
CacheStatus cacheStatus = getCacheStatus(cachingConfiguration, versionCacheDir);
217+
if (cacheStatus == CacheStatus.DOES_NOT_EXIST || cacheStatus == CacheStatus.EXPIRED) {
218+
retrieveLock.readLock().unlock();
219+
retrieveLock.writeLock().lockInterruptibly();
220+
try {
221+
boolean retrieve = false;
222+
switch (getCacheStatus(cachingConfiguration, versionCacheDir)) {
223+
case VALID:
224+
listener.getLogger().println("Library " + name + "@" + version + " is cached. Copying from home.");
225+
break;
226+
case DOES_NOT_EXIST:
227+
retrieve = true;
228+
break;
229+
case EXPIRED:
230+
long cachingMinutes = cachingConfiguration.getRefreshTimeMinutes();
231+
listener.getLogger().println("Library " + name + "@" + version + " is due for a refresh after " + cachingMinutes + " minutes, clearing.");
232+
if (versionCacheDir.exists()) {
233+
versionCacheDir.deleteRecursive();
234+
versionCacheDir.withSuffix("-name.txt").delete();
235+
}
236+
retrieve = true;
237+
break;
238+
}
239+
240+
if (retrieve) {
241+
listener.getLogger().println("Caching library " + name + "@" + version);
242+
versionCacheDir.mkdirs();
243+
retriever.retrieve(name, version, changelog, versionCacheDir, run, listener);
244+
}
245+
retrieveLock.readLock().lock();
246+
} finally {
247+
retrieveLock.writeLock().unlock();
248+
}
249+
} else {
250+
listener.getLogger().println("Library " + name + "@" + version + " is cached. Copying from home.");
189251
}
252+
253+
lastReadFile.touch(System.currentTimeMillis());
254+
versionCacheDir.withSuffix("-name.txt").write(name, "UTF-8");
255+
versionCacheDir.copyRecursiveTo(libDir);
256+
} finally {
257+
retrieveLock.readLock().unlock();
190258
}
191-
192-
if(versionCacheDir.exists()) {
193-
listener.getLogger().println("Library " + name + "@" + version + " is cached. Copying from home.");
194-
} else {
195-
listener.getLogger().println("Caching library " + name + "@" + version);
196-
versionCacheDir.mkdirs();
197-
retrieveLockFile.touch(System.currentTimeMillis());
198-
retriever.retrieve(name, version, changelog, versionCacheDir, run, listener);
199-
retrieveLockFile.delete();
200-
}
201-
lastReadFile.touch(System.currentTimeMillis());
202-
versionCacheDir.withSuffix("-name.txt").write(name, "UTF-8");
203-
versionCacheDir.copyRecursiveTo(libDir);
204259
} else {
205260
retriever.retrieve(name, version, changelog, libDir, run, listener);
206261
}

src/main/java/org/jenkinsci/plugins/workflow/libs/LibraryCachingCleanup.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import java.io.IOException;
1010
import java.util.concurrent.TimeUnit;
11+
import java.util.concurrent.locks.ReentrantReadWriteLock;
1112

1213
import jenkins.util.SystemProperties;
1314

@@ -48,9 +49,16 @@ public LibraryCachingCleanup() {
4849
private boolean removeIfExpiredCacheDirectory(FilePath library) throws IOException, InterruptedException {
4950
final FilePath lastReadFile = new FilePath(library, LibraryCachingConfiguration.LAST_READ_FILE);
5051
if (lastReadFile.exists()) {
51-
if (System.currentTimeMillis() - lastReadFile.lastModified() > TimeUnit.DAYS.toMillis(EXPIRE_AFTER_READ_DAYS)) {
52-
library.deleteRecursive();
53-
library.withSuffix("-name.txt").delete();
52+
ReentrantReadWriteLock retrieveLock = LibraryAdder.getReadWriteLockFor(library.getName());
53+
retrieveLock.writeLock().lockInterruptibly();
54+
try {
55+
if (System.currentTimeMillis() - lastReadFile.lastModified() > TimeUnit.DAYS.toMillis(EXPIRE_AFTER_READ_DAYS)) {
56+
57+
library.deleteRecursive();
58+
library.withSuffix("-name.txt").delete();
59+
}
60+
} finally {
61+
retrieveLock.writeLock().unlock();
5462
}
5563
return true;
5664
}

src/main/java/org/jenkinsci/plugins/workflow/libs/LibraryCachingConfiguration.java

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,30 @@
99
import org.kohsuke.stapler.DataBoundConstructor;
1010
import org.kohsuke.stapler.QueryParameter;
1111

12-
import java.io.File;
1312
import java.io.IOException;
1413
import java.io.InputStream;
1514
import java.nio.charset.StandardCharsets;
1615
import java.util.Arrays;
1716
import java.util.Collections;
1817
import java.util.List;
18+
import java.util.concurrent.TimeUnit;
19+
import java.util.concurrent.locks.ReentrantReadWriteLock;
20+
import java.util.logging.Level;
21+
import java.util.logging.Logger;
22+
1923
import org.apache.commons.io.IOUtils;
2024
import org.apache.commons.lang.StringUtils;
2125

2226
public final class LibraryCachingConfiguration extends AbstractDescribableImpl<LibraryCachingConfiguration> {
27+
28+
private static final Logger LOGGER = Logger.getLogger(LibraryCachingConfiguration.class.getName());
29+
2330
private int refreshTimeMinutes;
2431
private String excludedVersionsStr;
2532

2633
private static final String VERSIONS_SEPARATOR = " ";
2734
public static final String GLOBAL_LIBRARIES_DIR = "global-libraries-cache";
2835
public static final String LAST_READ_FILE = "last_read";
29-
public static final String RETRIEVE_LOCK_FILE = "retrieve.lock";
3036

3137
@DataBoundConstructor public LibraryCachingConfiguration(int refreshTimeMinutes, String excludedVersionsStr) {
3238
this.refreshTimeMinutes = refreshTimeMinutes;
@@ -83,7 +89,7 @@ public static FilePath getGlobalLibrariesCacheDir() {
8389
}
8490

8591
@Extension public static class DescriptorImpl extends Descriptor<LibraryCachingConfiguration> {
86-
public FormValidation doClearCache(@QueryParameter String name) throws InterruptedException {
92+
public FormValidation doClearCache(@QueryParameter String name, @QueryParameter boolean forceDelete) throws InterruptedException {
8793
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
8894

8995
try {
@@ -94,11 +100,27 @@ public FormValidation doClearCache(@QueryParameter String name) throws Interrupt
94100
try (InputStream stream = libraryNamePath.read()) {
95101
cacheName = IOUtils.toString(stream, StandardCharsets.UTF_8);
96102
}
97-
if (libraryNamePath.readToString().equals(name)) {
103+
if (cacheName.equals(name)) {
98104
FilePath libraryCachePath = LibraryCachingConfiguration.getGlobalLibrariesCacheDir()
99105
.child(libraryNamePath.getName().replace("-name.txt", ""));
100-
libraryCachePath.deleteRecursive();
101-
libraryNamePath.delete();
106+
if (forceDelete) {
107+
LOGGER.log(Level.FINER, "Force deleting cache for {0}", name);
108+
libraryCachePath.deleteRecursive();
109+
libraryNamePath.delete();
110+
} else {
111+
LOGGER.log(Level.FINER, "Safe deleting cache for {0}", name);
112+
ReentrantReadWriteLock retrieveLock = LibraryAdder.getReadWriteLockFor(libraryCachePath.getName());
113+
if (retrieveLock.writeLock().tryLock(10, TimeUnit.SECONDS)) {
114+
try {
115+
libraryCachePath.deleteRecursive();
116+
libraryNamePath.delete();
117+
} finally {
118+
retrieveLock.writeLock().unlock();
119+
}
120+
} else {
121+
return FormValidation.error("The cache dir could not be deleted because it is currently being used by another thread. Please try again.");
122+
}
123+
}
102124
}
103125
}
104126
}

src/main/resources/org/jenkinsci/plugins/workflow/libs/LibraryCachingConfiguration/config.jelly

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ THE SOFTWARE.
3232
<f:textbox />
3333
</f:entry>
3434
<j:if test="${h.hasPermission(app.ADMINISTER)}">
35-
<f:validateButton title="${%Clear cache}" progress="${%Clearing...}" method="clearCache" with="name" />
35+
<f:entry title="${%Force clear cache}" field="forceDelete">
36+
<f:checkbox/>
37+
</f:entry>
38+
<f:validateButton title="${%Clear cache}" progress="${%Clearing...}" method="clearCache" with="name,forceDelete" />
3639
</j:if>
3740
</j:jelly>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div>
2+
To avoid that clearing the cache interferes with running builds this process is normally guarded by locks.
3+
Pressing the <i>Clear Cache</i> button will wait for a maximum of 10 seconds to get a lock.<br/>
4+
By checking this option, the library will be deleted immediately without acquiring a lock. This can lead to build errors
5+
if a build is copying the library from the cache while it gets deleted. This risk is all the greater, the larger the library is.
6+
</div>

src/test/java/org/jenkinsci/plugins/workflow/libs/LibraryAdderTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@
2828
import hudson.FilePath;
2929
import hudson.model.Job;
3030
import hudson.model.Result;
31+
import hudson.model.queue.QueueTaskFuture;
3132
import hudson.plugins.git.BranchSpec;
3233
import hudson.plugins.git.GitSCM;
3334
import hudson.plugins.git.UserRemoteConfig;
3435
import hudson.scm.ChangeLogSet;
3536
import hudson.scm.SubversionSCM;
3637
import hudson.slaves.WorkspaceList;
38+
import java.time.ZonedDateTime;
3739
import java.util.Collection;
3840
import java.util.Collections;
3941
import java.util.Iterator;
@@ -473,6 +475,47 @@ public void correctLibraryDirectoryUsedWhenResumingOldBuild() throws Exception {
473475
r.assertLogContains("called Foo", b);
474476
}
475477

478+
@Issue("JENKINS-66898")
479+
@Test
480+
public void parallelBuildsDontInterfereWithExpiredCache() throws Throwable {
481+
// Add a few files to the library so the deletion is not too fast
482+
// Before fixing JENKINS-66898 this test was failing almost always
483+
// with a build failure
484+
sampleRepo.init();
485+
sampleRepo.write("vars/foo.groovy", "def call() { echo 'foo' }");
486+
sampleRepo.write("vars/bar.groovy", "def call() { echo 'bar' }");
487+
sampleRepo.write("vars/foo2.groovy", "def call() { echo 'foo2' }");
488+
sampleRepo.write("vars/foo3.groovy", "def call() { echo 'foo3' }");
489+
sampleRepo.write("vars/foo4.groovy", "def call() { echo 'foo4' }");
490+
sampleRepo.git("add", "vars");
491+
sampleRepo.git("commit", "--message=init");
492+
LibraryConfiguration config = new LibraryConfiguration("library",
493+
new SCMSourceRetriever(new GitSCMSource(null, sampleRepo.toString(), "", "*", "", true)));
494+
config.setDefaultVersion("master");
495+
config.setImplicit(true);
496+
config.setCachingConfiguration(new LibraryCachingConfiguration(30, null));
497+
GlobalLibraries.get().getLibraries().add(config);
498+
WorkflowJob p1 = r.createProject(WorkflowJob.class);
499+
WorkflowJob p2 = r.createProject(WorkflowJob.class);
500+
p1.setDefinition(new CpsFlowDefinition("foo()", true));
501+
p2.setDefinition(new CpsFlowDefinition("foo()", true));
502+
WorkflowRun b1 = r.buildAndAssertSuccess(p1);
503+
LibrariesAction action = b1.getAction(LibrariesAction.class);
504+
LibraryRecord record = action.getLibraries().get(0);
505+
FilePath cache = LibraryCachingConfiguration.getGlobalLibrariesCacheDir().child(record.getDirectoryName());
506+
//Expire the cache
507+
long oldMillis = ZonedDateTime.now().minusMinutes(35).toInstant().toEpochMilli();
508+
cache.touch(oldMillis);
509+
QueueTaskFuture<WorkflowRun> f1 = p1.scheduleBuild2(0);
510+
QueueTaskFuture<WorkflowRun> f2 = p2.scheduleBuild2(0);
511+
r.assertBuildStatus(Result.SUCCESS, f1);
512+
r.assertBuildStatus(Result.SUCCESS, f2);
513+
// Disabling these 2 checks as they are flaky
514+
// Occasionally the second job runs first and then build output doesn't match
515+
// r.assertLogContains("is due for a refresh after", f1.get());
516+
// r.assertLogContains("Library library@master is cached. Copying from home.", f2.get());
517+
}
518+
476519
@Issue("JENKINS-68544")
477520
@WithoutJenkins
478521
@Test public void className() {

src/test/java/org/jenkinsci/plugins/workflow/libs/LibraryCachingConfigurationTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ public void clearCache() throws Exception {
177177
assertThat(new File(cache.getRemote()), anExistingDirectory());
178178
assertThat(new File(cache.withSuffix("-name.txt").getRemote()), anExistingFile());
179179
// Clear the cache. TODO: Would be more realistic to set up security and use WebClient.
180-
ExtensionList.lookupSingleton(LibraryCachingConfiguration.DescriptorImpl.class).doClearCache("library");
180+
ExtensionList.lookupSingleton(LibraryCachingConfiguration.DescriptorImpl.class).doClearCache("library", false);
181181
assertThat(new File(cache.getRemote()), not(anExistingDirectory()));
182182
assertThat(new File(cache.withSuffix("-name.txt").getRemote()), not(anExistingFile()));
183183
}

0 commit comments

Comments
 (0)