Skip to content

Commit ae56d43

Browse files
authored
PeepholePermalink.Cache (jenkinsci#10042)
1 parent e4bff05 commit ae56d43

File tree

4 files changed

+228
-96
lines changed

4 files changed

+228
-96
lines changed

core/src/main/java/hudson/model/Job.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -853,7 +853,7 @@ public RunT getBuildForCLI(@Argument(required = true, metaVar = "BUILD#", usage
853853
}
854854

855855
/**
856-
* Gets the youngest build #m that satisfies {@code n<=m}.
856+
* Gets the oldest build #m that satisfies {@code m ≥ n}.
857857
*
858858
* This is useful when you'd like to fetch a build but the exact build might
859859
* be already gone (deleted, rotated, etc.)
@@ -868,7 +868,7 @@ public RunT getNearestBuild(int n) {
868868
}
869869

870870
/**
871-
* Gets the latest build #m that satisfies {@code m<=n}.
871+
* Gets the newest build #m that satisfies {@code mn}.
872872
*
873873
* This is useful when you'd like to fetch a build but the exact build might
874874
* be already gone (deleted, rotated, etc.)
@@ -977,7 +977,7 @@ public File getBuildDir() {
977977
protected abstract void removeRun(RunT run);
978978

979979
/**
980-
* Returns the last build.
980+
* Returns the newest build.
981981
* @see LazyBuildMixIn#getLastBuild
982982
*/
983983
@Exported

core/src/main/java/jenkins/model/PeepholePermalink.java

+187-92
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import edu.umd.cs.findbugs.annotations.NonNull;
55
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
66
import hudson.Extension;
7+
import hudson.ExtensionList;
8+
import hudson.ExtensionPoint;
79
import hudson.Util;
810
import hudson.model.Job;
911
import hudson.model.PermalinkProjectAction.Permalink;
@@ -14,6 +16,7 @@
1416
import hudson.util.AtomicFileWriter;
1517
import java.io.File;
1618
import java.io.IOException;
19+
import java.io.Serializable;
1720
import java.nio.charset.StandardCharsets;
1821
import java.nio.file.Files;
1922
import java.util.HashMap;
@@ -24,6 +27,7 @@
2427
import java.util.logging.Logger;
2528
import java.util.stream.Stream;
2629
import org.kohsuke.accmod.Restricted;
30+
import org.kohsuke.accmod.restrictions.Beta;
2731
import org.kohsuke.accmod.restrictions.NoExternalUse;
2832

2933
/**
@@ -63,14 +67,6 @@
6367
*/
6468
public abstract class PeepholePermalink extends Permalink implements Predicate<Run<?, ?>> {
6569

66-
/**
67-
* JENKINS-22822: avoids rereading caches.
68-
* Top map keys are {@code builds} directories.
69-
* Inner maps are from permalink name to build number.
70-
* Synchronization is first on the outer map, then on the inner.
71-
*/
72-
private static final Map<File, Map<String, Integer>> caches = new HashMap<>();
73-
7470
/**
7571
* Checks if the given build satisfies the peep-hole criteria.
7672
*
@@ -94,115 +90,216 @@ protected File getPermalinkFile(Job<?, ?> job) {
9490
*/
9591
@Override
9692
public Run<?, ?> resolve(Job<?, ?> job) {
97-
Map<String, Integer> cache = cacheFor(job.getBuildDir());
98-
int n;
99-
synchronized (cache) {
100-
n = cache.getOrDefault(getId(), 0);
93+
return ExtensionList.lookupFirst(Cache.class).get(job, getId()).resolve(this, job, getId());
94+
}
95+
96+
/**
97+
* Start from the build 'b' and locate the build that matches the criteria going back in time
98+
*/
99+
@CheckForNull
100+
private Run<?, ?> find(@CheckForNull Run<?, ?> b) {
101+
while (b != null && !apply(b)) {
102+
b = b.getPreviousBuild();
101103
}
102-
if (n == RESOLVES_TO_NONE) {
103-
return null;
104+
return b;
105+
}
106+
107+
/**
108+
* Remembers the value 'n' in the cache for future {@link #resolve(Job)}.
109+
*/
110+
protected void updateCache(@NonNull Job<?, ?> job, @CheckForNull Run<?, ?> b) {
111+
ExtensionList.lookupFirst(Cache.class).put(job, getId(), b != null ? new Cache.Some(b.getNumber()) : Cache.NONE);
112+
}
113+
114+
/**
115+
* Persistable cache of peephole permalink targets.
116+
*/
117+
@Restricted(Beta.class)
118+
public interface Cache extends ExtensionPoint {
119+
120+
/** Cacheable target of a permalink. */
121+
sealed interface PermalinkTarget extends Serializable {
122+
123+
/**
124+
* Implementation of {@link #resolve(Job)}.
125+
* This may update the cache if it was missing or found to be invalid.
126+
*/
127+
@Restricted(NoExternalUse.class)
128+
@CheckForNull
129+
Run<?, ?> resolve(@NonNull PeepholePermalink pp, @NonNull Job<?, ?> job, @NonNull String id);
130+
131+
/**
132+
* Partial implementation of {@link #resolve(PeepholePermalink, Job, String)} when searching.
133+
* @param b if set, the newest build to even consider when searching
134+
*/
135+
@Restricted(NoExternalUse.class)
136+
@CheckForNull
137+
default Run<?, ?> search(@NonNull PeepholePermalink pp, @NonNull Job<?, ?> job, @NonNull String id, @CheckForNull Run<?, ?> b) {
138+
if (b == null) {
139+
// no cache
140+
b = job.getLastBuild();
141+
}
142+
// start from the build 'b' and locate the build that matches the criteria going back in time
143+
b = pp.find(b);
144+
pp.updateCache(job, b);
145+
return b;
146+
}
147+
104148
}
105-
Run<?, ?> b;
106-
if (n > 0) {
107-
b = job.getBuildByNumber(n);
108-
if (b != null && apply(b)) {
109-
return b; // found it (in the most efficient way possible)
149+
150+
/**
151+
* The cache entry for this target is missing.
152+
*/
153+
record Unknown() implements PermalinkTarget {
154+
@Override
155+
public Run<?, ?> resolve(PeepholePermalink pp, Job<?, ?> job, String id) {
156+
return search(pp, job, id, null);
110157
}
111-
} else {
112-
b = null;
113158
}
114159

115-
// the cache is stale. start the search
116-
if (b == null) {
117-
b = job.getNearestOldBuild(n);
160+
Unknown UNKNOWN = new Unknown();
161+
162+
/**
163+
* The cache entry for this target is present.
164+
*/
165+
sealed interface Known extends PermalinkTarget {}
166+
167+
/** There is known to be no matching build. */
168+
record None() implements Known {
169+
@Override
170+
public Run<?, ?> resolve(PeepholePermalink pp, Job<?, ?> job, String id) {
171+
return null;
172+
}
118173
}
119174

120-
if (b == null) {
121-
// no cache
122-
b = job.getLastBuild();
175+
/** Singleton of {@link None}. */
176+
None NONE = new None();
177+
178+
/** A matching build, indicated by {@link Run#getNumber}. */
179+
record Some(int number) implements Known {
180+
@Override
181+
public Run<?, ?> resolve(PeepholePermalink pp, Job<?, ?> job, String id) {
182+
Run<?, ?> b = job.getBuildByNumber(number);
183+
if (b != null && pp.apply(b)) {
184+
return b; // found it (in the most efficient way possible)
185+
}
186+
// the cache is stale. start the search
187+
if (b == null) {
188+
b = job.getNearestOldBuild(number);
189+
}
190+
return search(pp, job, id, b);
191+
}
123192
}
124193

125-
// start from the build 'b' and locate the build that matches the criteria going back in time
126-
b = find(b);
194+
/**
195+
* Looks for any existing cache hit.
196+
* @param id {@link #getId}
197+
* @return {@link Some} or {@link #NONE} or {@link #UNKNOWN}
198+
*/
199+
@NonNull PermalinkTarget get(@NonNull Job<?, ?> job, @NonNull String id);
127200

128-
updateCache(job, b);
129-
return b;
201+
/**
202+
* Updates the cache.
203+
* Note that this may be called not just when a build completes or is deleted
204+
* (meaning that the logical value of the cache has changed),
205+
* but also when {@link #resolve} has failed to find a cached value
206+
* (or determined that a previously cached value is in fact invalid).
207+
* @param id {@link #getId}
208+
* @param target {@link Some} or {@link #NONE}
209+
*/
210+
void put(@NonNull Job<?, ?> job, @NonNull String id, @NonNull Known target);
130211
}
131212

132213
/**
133-
* Start from the build 'b' and locate the build that matches the criteria going back in time
214+
* Default cache based on a {@code permalinks} file in the build directory.
215+
* There is one line per cached permalink, in the format {@code lastStableBuild 123}
216+
* or (for a negative cache) {@code lastFailedBuild -1}.
134217
*/
135-
private Run<?, ?> find(Run<?, ?> b) {
136-
//noinspection StatementWithEmptyBody
137-
for ( ; b != null && !apply(b); b = b.getPreviousBuild())
138-
;
139-
return b;
140-
}
218+
@Restricted(NoExternalUse.class)
219+
@Extension(ordinal = -1000)
220+
public static final class DefaultCache implements Cache {
221+
222+
/**
223+
* JENKINS-22822: avoids rereading caches.
224+
* Top map keys are {@code builds} directories.
225+
* Inner maps are from permalink name to target.
226+
* Synchronization is first on the outer map, then on the inner.
227+
*/
228+
private final Map<File, Map<String, Known>> caches = new HashMap<>();
141229

142-
private static @NonNull Map<String, Integer> cacheFor(@NonNull File buildDir) {
143-
synchronized (caches) {
144-
Map<String, Integer> cache = caches.get(buildDir);
145-
if (cache == null) {
146-
cache = load(buildDir);
147-
caches.put(buildDir, cache);
230+
@Override
231+
public PermalinkTarget get(Job<?, ?> job, String id) {
232+
var cache = cacheFor(job.getBuildDir());
233+
synchronized (cache) {
234+
var cached = cache.get(id);
235+
return cached != null ? cached : UNKNOWN;
148236
}
149-
return cache;
150237
}
151-
}
152238

153-
private static @NonNull Map<String, Integer> load(@NonNull File buildDir) {
154-
Map<String, Integer> cache = new TreeMap<>();
155-
File storage = storageFor(buildDir);
156-
if (storage.isFile()) {
157-
try (Stream<String> lines = Files.lines(storage.toPath(), StandardCharsets.UTF_8)) {
158-
lines.forEach(line -> {
159-
int idx = line.indexOf(' ');
160-
if (idx == -1) {
161-
return;
162-
}
239+
@Override
240+
public void put(Job<?, ?> job, String id, Known target) {
241+
File buildDir = job.getBuildDir();
242+
var cache = cacheFor(buildDir);
243+
synchronized (cache) {
244+
cache.put(id, target);
245+
File storage = storageFor(buildDir);
246+
LOGGER.fine(() -> "saving to " + storage + ": " + cache);
247+
try (AtomicFileWriter cw = new AtomicFileWriter(storage)) {
163248
try {
164-
cache.put(line.substring(0, idx), Integer.parseInt(line.substring(idx + 1)));
165-
} catch (NumberFormatException x) {
166-
LOGGER.log(Level.WARNING, "failed to read " + storage, x);
249+
for (var entry : cache.entrySet()) {
250+
cw.write(entry.getKey());
251+
cw.write(' ');
252+
cw.write(Integer.toString(entry.getValue() instanceof Cache.Some some ? some.number : -1));
253+
cw.write('\n');
254+
}
255+
cw.commit();
256+
} finally {
257+
cw.abort();
167258
}
168-
});
169-
} catch (IOException x) {
170-
LOGGER.log(Level.WARNING, "failed to read " + storage, x);
259+
} catch (IOException x) {
260+
LOGGER.log(Level.WARNING, "failed to update " + storage, x);
261+
}
171262
}
172-
LOGGER.fine(() -> "loading from " + storage + ": " + cache);
173263
}
174-
return cache;
175-
}
176264

177-
static @NonNull File storageFor(@NonNull File buildDir) {
178-
return new File(buildDir, "permalinks");
179-
}
265+
private @NonNull Map<String, Known> cacheFor(@NonNull File buildDir) {
266+
synchronized (caches) {
267+
var cache = caches.get(buildDir);
268+
if (cache == null) {
269+
cache = load(buildDir);
270+
caches.put(buildDir, cache);
271+
}
272+
return cache;
273+
}
274+
}
180275

181-
/**
182-
* Remembers the value 'n' in the cache for future {@link #resolve(Job)}.
183-
*/
184-
protected void updateCache(@NonNull Job<?, ?> job, @CheckForNull Run<?, ?> b) {
185-
File buildDir = job.getBuildDir();
186-
Map<String, Integer> cache = cacheFor(buildDir);
187-
synchronized (cache) {
188-
cache.put(getId(), b == null ? RESOLVES_TO_NONE : b.getNumber());
276+
private static @NonNull Map<String, Known> load(@NonNull File buildDir) {
277+
Map<String, Known> cache = new TreeMap<>();
189278
File storage = storageFor(buildDir);
190-
LOGGER.fine(() -> "saving to " + storage + ": " + cache);
191-
try (AtomicFileWriter cw = new AtomicFileWriter(storage)) {
192-
try {
193-
for (Map.Entry<String, Integer> entry : cache.entrySet()) {
194-
cw.write(entry.getKey());
195-
cw.write(' ');
196-
cw.write(Integer.toString(entry.getValue()));
197-
cw.write('\n');
198-
}
199-
cw.commit();
200-
} finally {
201-
cw.abort();
279+
if (storage.isFile()) {
280+
try (Stream<String> lines = Files.lines(storage.toPath(), StandardCharsets.UTF_8)) {
281+
lines.forEach(line -> {
282+
int idx = line.indexOf(' ');
283+
if (idx == -1) {
284+
return;
285+
}
286+
try {
287+
int number = Integer.parseInt(line.substring(idx + 1));
288+
cache.put(line.substring(0, idx), number == -1 ? Cache.NONE : new Cache.Some(number));
289+
} catch (NumberFormatException x) {
290+
LOGGER.log(Level.WARNING, "failed to read " + storage, x);
291+
}
292+
});
293+
} catch (IOException x) {
294+
LOGGER.log(Level.WARNING, "failed to read " + storage, x);
202295
}
203-
} catch (IOException x) {
204-
LOGGER.log(Level.WARNING, "failed to update " + storage, x);
296+
LOGGER.fine(() -> "loading from " + storage + ": " + cache);
205297
}
298+
return cache;
299+
}
300+
301+
static @NonNull File storageFor(@NonNull File buildDir) {
302+
return new File(buildDir, "permalinks");
206303
}
207304
}
208305

@@ -380,7 +477,5 @@ public boolean apply(Run<?, ?> run) {
380477
@Restricted(NoExternalUse.class)
381478
public static void initialized() {}
382479

383-
private static final int RESOLVES_TO_NONE = -1;
384-
385480
private static final Logger LOGGER = Logger.getLogger(PeepholePermalink.class.getName());
386481
}

test/src/test/java/jenkins/model/PeepholePermalinkTest.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public void basics() throws Exception {
8282
}
8383

8484
private void assertStorage(String id, Job<?, ?> job, Run<?, ?> build) throws Exception {
85-
assertThat(Files.readAllLines(PeepholePermalink.storageFor(job.getBuildDir()).toPath(), StandardCharsets.UTF_8),
85+
assertThat(Files.readAllLines(PeepholePermalink.DefaultCache.storageFor(job.getBuildDir()).toPath(), StandardCharsets.UTF_8),
8686
hasItem(id + " " + (build == null ? -1 : build.getNumber())));
8787
}
8888

0 commit comments

Comments
 (0)