Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PeepholePermalink.Cache #10042

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
6 changes: 3 additions & 3 deletions core/src/main/java/hudson/model/Job.java
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ public RunT getBuildForCLI(@Argument(required = true, metaVar = "BUILD#", usage
}

/**
* Gets the youngest build #m that satisfies {@code n<=m}.
* Gets the oldest build #m that satisfies {@code m ≥ n}.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getNearestBuild is not used by the code touched here, but I noticed that its Javadoc was simply wrong: it finds the oldest build newer than a given point, not the youngest build!

Also the comparison was phrased in the opposite direction of what you might intuitively expect.

*
* This is useful when you'd like to fetch a build but the exact build might
* be already gone (deleted, rotated, etc.)
Expand All @@ -868,7 +868,7 @@ public RunT getNearestBuild(int n) {
}

/**
* Gets the latest build #m that satisfies {@code m<=n}.
* Gets the newest build #m that satisfies {@code mn}.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arguably clearer phrasing. Encountered while studying the behavior of getNearestOldBuild(0) (see elsewhere).

*
* This is useful when you'd like to fetch a build but the exact build might
* be already gone (deleted, rotated, etc.)
Expand Down Expand Up @@ -977,7 +977,7 @@ public File getBuildDir() {
protected abstract void removeRun(RunT run);

/**
* Returns the last build.
* Returns the newest build.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than repeating the terminology used in the method name, trying to clarify.

* @see LazyBuildMixIn#getLastBuild
*/
@Exported
Expand Down
279 changes: 187 additions & 92 deletions core/src/main/java/jenkins/model/PeepholePermalink.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.Extension;
import hudson.ExtensionList;
import hudson.ExtensionPoint;
import hudson.Util;
import hudson.model.Job;
import hudson.model.PermalinkProjectAction.Permalink;
Expand All @@ -14,6 +16,7 @@
import hudson.util.AtomicFileWriter;
import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.HashMap;
Expand All @@ -24,6 +27,7 @@
import java.util.logging.Logger;
import java.util.stream.Stream;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
import org.kohsuke.accmod.restrictions.NoExternalUse;

/**
Expand Down Expand Up @@ -63,14 +67,6 @@
*/
public abstract class PeepholePermalink extends Permalink implements Predicate<Run<?, ?>> {

/**
* JENKINS-22822: avoids rereading caches.
* Top map keys are {@code builds} directories.
* Inner maps are from permalink name to build number.
* Synchronization is first on the outer map, then on the inner.
*/
private static final Map<File, Map<String, Integer>> caches = new HashMap<>();

/**
* Checks if the given build satisfies the peep-hole criteria.
*
Expand All @@ -94,115 +90,216 @@
*/
@Override
public Run<?, ?> resolve(Job<?, ?> job) {
Map<String, Integer> cache = cacheFor(job.getBuildDir());
int n;
synchronized (cache) {
n = cache.getOrDefault(getId(), 0);
return ExtensionList.lookupFirst(Cache.class).get(job, getId()).resolve(this, job, getId());
}

/**
* Start from the build 'b' and locate the build that matches the criteria going back in time
*/
@CheckForNull
private Run<?, ?> find(@CheckForNull Run<?, ?> b) {
while (b != null && !apply(b)) {
b = b.getPreviousBuild();
}
if (n == RESOLVES_TO_NONE) {
return null;
return b;
}

/**
* Remembers the value 'n' in the cache for future {@link #resolve(Job)}.
*/
protected void updateCache(@NonNull Job<?, ?> job, @CheckForNull Run<?, ?> b) {
ExtensionList.lookupFirst(Cache.class).put(job, getId(), b != null ? new Cache.Some(b.getNumber()) : Cache.NONE);
}

/**
* Persistable cache of peephole permalink targets.
*/
@Restricted(Beta.class)
public interface Cache extends ExtensionPoint {

/** Cacheable target of a permalink. */
sealed interface PermalinkTarget extends Serializable {

/**
* Implementation of {@link #resolve(Job)}.
* This may update the cache if it was missing or found to be invalid.
*/
@Restricted(NoExternalUse.class)
@CheckForNull
Run<?, ?> resolve(@NonNull PeepholePermalink pp, @NonNull Job<?, ?> job, @NonNull String id);

/**
* Partial implementation of {@link #resolve(PeepholePermalink, Job, String)} when searching.
Copy link
Member Author

@jglick jglick Dec 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(broken out of the original resolve method; the rest is mostly now in Some)

* @param b if set, the newest build to even consider when searching
*/
@Restricted(NoExternalUse.class)
@CheckForNull
default Run<?, ?> search(@NonNull PeepholePermalink pp, @NonNull Job<?, ?> job, @NonNull String id, @CheckForNull Run<?, ?> b) {
if (b == null) {
// no cache
b = job.getLastBuild();
}
// start from the build 'b' and locate the build that matches the criteria going back in time
b = pp.find(b);
pp.updateCache(job, b);
return b;
}

}
Run<?, ?> b;
if (n > 0) {
b = job.getBuildByNumber(n);
if (b != null && apply(b)) {
return b; // found it (in the most efficient way possible)

/**
* The cache entry for this target is missing.
*/
record Unknown() implements PermalinkTarget {
@Override
public Run<?, ?> resolve(PeepholePermalink pp, Job<?, ?> job, String id) {
return search(pp, job, id, null);
}
} else {
b = null;
}

// the cache is stale. start the search
if (b == null) {
b = job.getNearestOldBuild(n);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a while to realize that this was sometimes called with n == 0 in which case the return value of getNearestOldBuild was unconditionally null, so the clause had no effect (b == null before and after). Now this method is called only when processing Some, with a positive argument.

Unknown UNKNOWN = new Unknown();

/**
* The cache entry for this target is present.
*/
sealed interface Known extends PermalinkTarget {}

/** There is known to be no matching build. */
record None() implements Known {
@Override
public Run<?, ?> resolve(PeepholePermalink pp, Job<?, ?> job, String id) {
return null;
}
}

if (b == null) {
// no cache
b = job.getLastBuild();
/** Singleton of {@link None}. */
None NONE = new None();

/** A matching build, indicated by {@link Run#getNumber}. */
record Some(int number) implements Known {
@Override
public Run<?, ?> resolve(PeepholePermalink pp, Job<?, ?> job, String id) {
Run<?, ?> b = job.getBuildByNumber(number);
if (b != null && pp.apply(b)) {

Check warning on line 183 in core/src/main/java/jenkins/model/PeepholePermalink.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 183 is only partially covered, one branch is missing
return b; // found it (in the most efficient way possible)
}
// the cache is stale. start the search
if (b == null) {

Check warning on line 187 in core/src/main/java/jenkins/model/PeepholePermalink.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 187 is only partially covered, one branch is missing
b = job.getNearestOldBuild(number);

Check warning on line 188 in core/src/main/java/jenkins/model/PeepholePermalink.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 188 is not covered by tests
}
return search(pp, job, id, b);
}
}

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

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

/**
* Start from the build 'b' and locate the build that matches the criteria going back in time
* Default cache based on a {@code permalinks} file in the build directory.
* There is one line per cached permalink, in the format {@code lastStableBuild 123}
* or (for a negative cache) {@code lastFailedBuild -1}.
*/
private Run<?, ?> find(Run<?, ?> b) {
//noinspection StatementWithEmptyBody
for ( ; b != null && !apply(b); b = b.getPreviousBuild())
;
Comment on lines -136 to -138
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Git diff unfortunately fails to associate this hunk with the original method call. I just converted the body into a while loop for clarity.

return b;
}
@Restricted(NoExternalUse.class)
@Extension(ordinal = -1000)
public static final class DefaultCache implements Cache {

/**
* JENKINS-22822: avoids rereading caches.
* Top map keys are {@code builds} directories.
* Inner maps are from permalink name to target.
* Synchronization is first on the outer map, then on the inner.
*/
private final Map<File, Map<String, Known>> caches = new HashMap<>();

private static @NonNull Map<String, Integer> cacheFor(@NonNull File buildDir) {
synchronized (caches) {
Map<String, Integer> cache = caches.get(buildDir);
if (cache == null) {
cache = load(buildDir);
caches.put(buildDir, cache);
@Override
public PermalinkTarget get(Job<?, ?> job, String id) {
var cache = cacheFor(job.getBuildDir());
synchronized (cache) {
var cached = cache.get(id);
return cached != null ? cached : UNKNOWN;
Comment on lines +234 to +235
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that Map.getOrDefault cannot be used because it is not typed to permit a return value wider than the map value type.

}
return cache;
}
}

private static @NonNull Map<String, Integer> load(@NonNull File buildDir) {
Map<String, Integer> cache = new TreeMap<>();
File storage = storageFor(buildDir);
if (storage.isFile()) {
try (Stream<String> lines = Files.lines(storage.toPath(), StandardCharsets.UTF_8)) {
lines.forEach(line -> {
int idx = line.indexOf(' ');
if (idx == -1) {
return;
}
@Override
public void put(Job<?, ?> job, String id, Known target) {
File buildDir = job.getBuildDir();
var cache = cacheFor(buildDir);
synchronized (cache) {
cache.put(id, target);
File storage = storageFor(buildDir);
LOGGER.fine(() -> "saving to " + storage + ": " + cache);
try (AtomicFileWriter cw = new AtomicFileWriter(storage)) {
try {
cache.put(line.substring(0, idx), Integer.parseInt(line.substring(idx + 1)));
} catch (NumberFormatException x) {
LOGGER.log(Level.WARNING, "failed to read " + storage, x);
for (var entry : cache.entrySet()) {
cw.write(entry.getKey());
cw.write(' ');
cw.write(Integer.toString(entry.getValue() instanceof Cache.Some some ? some.number : -1));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this can be written more clearly in Java 21:

--- core/src/main/java/jenkins/model/PeepholePermalink.java
+++ core/src/main/java/jenkins/model/PeepholePermalink.java
@@ -248,7 +248,10 @@ public abstract class PeepholePermalink extends Permalink implements Predicate<R
                         for (var entry : cache.entrySet()) {
                             cw.write(entry.getKey());
                             cw.write(' ');
-                            cw.write(Integer.toString(entry.getValue() instanceof Cache.Some some ? some.number : -1));
+                            cw.write(Integer.toString(switch (entry.getValue()) {
+                                case Some some -> some.number;
+                                case None none -> -1;
+                            }));
                             cw.write('\n');
                         }
                         cw.commit();

cw.write('\n');
}
cw.commit();
} finally {
cw.abort();
}
});
} catch (IOException x) {
LOGGER.log(Level.WARNING, "failed to read " + storage, x);
} catch (IOException x) {
LOGGER.log(Level.WARNING, "failed to update " + storage, x);
}
}
LOGGER.fine(() -> "loading from " + storage + ": " + cache);
}
return cache;
}

static @NonNull File storageFor(@NonNull File buildDir) {
return new File(buildDir, "permalinks");
}
private @NonNull Map<String, Known> cacheFor(@NonNull File buildDir) {
synchronized (caches) {
var cache = caches.get(buildDir);
if (cache == null) {
cache = load(buildDir);
caches.put(buildDir, cache);
}
return cache;
}
}

/**
* Remembers the value 'n' in the cache for future {@link #resolve(Job)}.
*/
protected void updateCache(@NonNull Job<?, ?> job, @CheckForNull Run<?, ?> b) {
File buildDir = job.getBuildDir();
Map<String, Integer> cache = cacheFor(buildDir);
synchronized (cache) {
cache.put(getId(), b == null ? RESOLVES_TO_NONE : b.getNumber());
private static @NonNull Map<String, Known> load(@NonNull File buildDir) {
Map<String, Known> cache = new TreeMap<>();
File storage = storageFor(buildDir);
LOGGER.fine(() -> "saving to " + storage + ": " + cache);
try (AtomicFileWriter cw = new AtomicFileWriter(storage)) {
try {
for (Map.Entry<String, Integer> entry : cache.entrySet()) {
cw.write(entry.getKey());
cw.write(' ');
cw.write(Integer.toString(entry.getValue()));
cw.write('\n');
}
cw.commit();
} finally {
cw.abort();
if (storage.isFile()) {
try (Stream<String> lines = Files.lines(storage.toPath(), StandardCharsets.UTF_8)) {
lines.forEach(line -> {
int idx = line.indexOf(' ');
if (idx == -1) {
return;
}
try {
int number = Integer.parseInt(line.substring(idx + 1));
cache.put(line.substring(0, idx), number == -1 ? Cache.NONE : new Cache.Some(number));
} catch (NumberFormatException x) {
LOGGER.log(Level.WARNING, "failed to read " + storage, x);
}
});
} catch (IOException x) {
LOGGER.log(Level.WARNING, "failed to read " + storage, x);
}
} catch (IOException x) {
LOGGER.log(Level.WARNING, "failed to update " + storage, x);
LOGGER.fine(() -> "loading from " + storage + ": " + cache);
}
return cache;
}

static @NonNull File storageFor(@NonNull File buildDir) {
return new File(buildDir, "permalinks");
}
}

Expand Down Expand Up @@ -380,7 +477,5 @@
@Restricted(NoExternalUse.class)
public static void initialized() {}

private static final int RESOLVES_TO_NONE = -1;

private static final Logger LOGGER = Logger.getLogger(PeepholePermalink.class.getName());
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public void basics() throws Exception {
}

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

Expand Down
Loading
Loading