Skip to content

Commit d450b6e

Browse files
timjaCopilotlewisbirks
authored
Faster cache (#1253)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: lewisbirks <22620804+lewisbirks@users.noreply.github.com>
1 parent 1744307 commit d450b6e

10 files changed

Lines changed: 287 additions & 105 deletions

File tree

src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import io.jenkins.plugins.pipelinegraphview.cards.items.TestResultRunDetailsItem;
2020
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraph;
2121
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraphApi;
22+
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraphViewCache;
2223
import io.jenkins.plugins.pipelinegraphview.utils.PipelineJsonWriter;
2324
import io.jenkins.plugins.pipelinegraphview.utils.PipelineNodeUtil;
2425
import io.jenkins.plugins.pipelinegraphview.utils.PipelineStep;
@@ -99,9 +100,15 @@ public void getSteps(StaplerRequest2 req, StaplerResponse2 rsp) throws IOExcepti
99100
@WebMethod(name = "allSteps")
100101
public void getAllSteps(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException {
101102
run.checkPermission(Item.READ);
102-
PipelineStepList steps = stepApi.getAllSteps();
103103
rsp.setStatus(200);
104104
rsp.setContentType("application/json;charset=UTF-8");
105+
// Speculative: a cache hit always implies the run was complete when persisted.
106+
// Overwritten below if we fall through to the compute path.
107+
setCache(rsp, true);
108+
if (PipelineGraphViewCache.get().tryServeAllSteps(run, rsp.getOutputStream())) {
109+
return;
110+
}
111+
PipelineStepList steps = stepApi.getAllSteps();
105112
setCache(rsp, steps.runIsComplete);
106113
PipelineJsonWriter.write(steps, rsp.getOutputStream());
107114
}
@@ -383,10 +390,13 @@ public String getNextBuildNumber() {
383390
@WebMethod(name = "tree")
384391
public void getTree(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException {
385392
run.checkPermission(Item.READ);
386-
387-
PipelineGraph tree = graphApi.createTree();
388393
rsp.setStatus(200);
389394
rsp.setContentType("application/json;charset=UTF-8");
395+
setCache(rsp, true);
396+
if (PipelineGraphViewCache.get().tryServeTree(run, rsp.getOutputStream())) {
397+
return;
398+
}
399+
PipelineGraph tree = graphApi.createTree();
390400
setCache(rsp, tree.complete);
391401
PipelineJsonWriter.write(tree, rsp.getOutputStream());
392402
}

src/main/java/io/jenkins/plugins/pipelinegraphview/utils/AbstractPipelineNode.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ public AbstractPipelineNode(
4747
this.idAsInt = Integer.parseInt(id);
4848
}
4949

50+
/**
51+
* Read-back constructor for the cached wire JSON. The wire shape splits {@code TimingInfo}
52+
* into three flat millis fields (and omits {@code totalDurationMillis} when in-progress);
53+
* we rebuild a {@link TimingInfo} so the rest of the object behaves identically to one
54+
* produced by the live compute path.
55+
*/
56+
protected AbstractPipelineNode(
57+
String id,
58+
String name,
59+
PipelineState state,
60+
String type,
61+
String title,
62+
long pauseDurationMillis,
63+
Long totalDurationMillis,
64+
long startTimeMillis,
65+
String causeOfBlockage) {
66+
this(
67+
id,
68+
name,
69+
state,
70+
type,
71+
title,
72+
new TimingInfo(
73+
totalDurationMillis != null ? totalDurationMillis : 0L, pauseDurationMillis, startTimeMillis),
74+
causeOfBlockage);
75+
}
76+
5077
public long getStartTimeMillis() {
5178
return timingInfo.getStartTimeMillis();
5279
}

src/main/java/io/jenkins/plugins/pipelinegraphview/utils/PipelineGraph.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package io.jenkins.plugins.pipelinegraphview.utils;
22

3+
import com.fasterxml.jackson.annotation.JsonCreator;
34
import java.util.List;
45

56
public class PipelineGraph {
67

78
final List<PipelineStage> stages;
89
public final boolean complete;
910

11+
@JsonCreator
1012
public PipelineGraph(List<PipelineStage> stages, boolean complete) {
1113
this.stages = stages;
1214
this.complete = complete;
Lines changed: 130 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,60 @@
11
package io.jenkins.plugins.pipelinegraphview.utils;
22

3+
import com.fasterxml.jackson.annotation.JsonAutoDetect;
4+
import com.fasterxml.jackson.annotation.JsonInclude;
35
import com.github.benmanes.caffeine.cache.Cache;
46
import com.github.benmanes.caffeine.cache.Caffeine;
5-
import hudson.XmlFile;
6-
import hudson.util.XStream2;
7-
import io.jenkins.plugins.pipelinegraphview.analysis.TimingInfo;
8-
import java.io.File;
7+
import java.io.BufferedInputStream;
8+
import java.io.BufferedOutputStream;
99
import java.io.IOException;
10+
import java.io.InputStream;
11+
import java.io.OutputStream;
12+
import java.nio.charset.StandardCharsets;
13+
import java.nio.file.AtomicMoveNotSupportedException;
14+
import java.nio.file.Files;
15+
import java.nio.file.Path;
16+
import java.nio.file.StandardCopyOption;
1017
import java.util.function.Supplier;
1118
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
1219
import org.slf4j.Logger;
1320
import org.slf4j.LoggerFactory;
21+
import tools.jackson.databind.ObjectMapper;
22+
import tools.jackson.databind.json.JsonMapper;
1423

1524
/**
1625
* Disk-backed cache for the computed pipeline graph and step list of completed runs.
1726
* For in-progress runs the cache is transparent (every call recomputes). Once a run is
18-
* no longer building, results are persisted under the run's directory, so later
19-
* requests — including after a Jenkins restart — are served without recomputation.
27+
* no longer building, results are persisted as JSON under the run's directory and can be
28+
* streamed straight back to HTTP clients without going through Jackson on the read path.
29+
*
30+
* <p>The on-disk format is the same wire JSON the {@code tree} / {@code allSteps} endpoints
31+
* emit (the {@code data} portion of the {@code {"status":"ok","data":...}} envelope).
32+
* {@link #tryServeTree(WorkflowRun, OutputStream)} / {@link #tryServeAllSteps(WorkflowRun,
33+
* OutputStream)} wrap that in the envelope and copy bytes through.
34+
*
35+
* <p>Schema version is encoded in the file name: a future format change just bumps
36+
* {@link #SCHEMA_VERSION} so old files become orphans on disk and are ignored.
2037
*/
2138
public class PipelineGraphViewCache {
2239

23-
/**
24-
* Hand-bump when any persisted DTO shape changes in a non-backwards-compatible way.
25-
* Older files are ignored and recomputed on the next read.
26-
*/
27-
static final int SCHEMA_VERSION = 1;
40+
public static final int SCHEMA_VERSION = 1;
41+
42+
public static final String TREE_FILE_NAME = "pipeline-graph-view-tree.v" + SCHEMA_VERSION + ".json";
43+
public static final String ALL_STEPS_FILE_NAME = "pipeline-graph-view-allsteps.v" + SCHEMA_VERSION + ".json";
44+
public static final String LEGACY_XSTREAM_FILE_NAME = "pipeline-graph-view-cache.xml";
45+
46+
private static final byte[] ENVELOPE_PREFIX = "{\"status\":\"ok\",\"data\":".getBytes(StandardCharsets.UTF_8);
47+
private static final byte[] ENVELOPE_SUFFIX = "}".getBytes(StandardCharsets.UTF_8);
2848

29-
private static final String CACHE_FILE_NAME = "pipeline-graph-view-cache.xml";
3049
private static final Logger logger = LoggerFactory.getLogger(PipelineGraphViewCache.class);
3150
private static final PipelineGraphViewCache INSTANCE = new PipelineGraphViewCache();
3251

33-
private final Cache<String, CachedPayload> memCache =
52+
private static final ObjectMapper MAPPER = JsonMapper.builder()
53+
.changeDefaultPropertyInclusion(inc -> inc.withValueInclusion(JsonInclude.Include.NON_NULL))
54+
.changeDefaultVisibility(v -> v.withFieldVisibility(JsonAutoDetect.Visibility.ANY))
55+
.build();
56+
57+
private final Cache<String, CachedValue> memCache =
3458
Caffeine.newBuilder().maximumSize(256).build();
3559

3660
public static PipelineGraphViewCache get() {
@@ -39,33 +63,62 @@ public static PipelineGraphViewCache get() {
3963

4064
PipelineGraphViewCache() {}
4165

66+
/**
67+
* If a cached graph file exists for {@code run}, write it to {@code out} wrapped in the
68+
* Stapler {@code okJSON} envelope and return {@code true}. Otherwise no bytes are written.
69+
*/
70+
public boolean tryServeTree(WorkflowRun run, OutputStream out) throws IOException {
71+
return tryServe(treeFile(run), out);
72+
}
73+
74+
/** {@link #tryServeTree} for the all-steps payload. */
75+
public boolean tryServeAllSteps(WorkflowRun run, OutputStream out) throws IOException {
76+
return tryServe(allStepsFile(run), out);
77+
}
78+
79+
private boolean tryServe(Path file, OutputStream out) throws IOException {
80+
if (!Files.exists(file)) {
81+
return false;
82+
}
83+
out.write(ENVELOPE_PREFIX);
84+
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
85+
in.transferTo(out);
86+
}
87+
out.write(ENVELOPE_SUFFIX);
88+
return true;
89+
}
90+
4291
public PipelineGraph getGraph(WorkflowRun run, Supplier<PipelineGraph> compute) {
4392
if (run.isBuilding()) {
4493
return compute.get();
4594
}
46-
CachedPayload payload = load(run);
47-
synchronized (payload) {
48-
if (payload.graph == null) {
49-
payload.graph = compute.get();
50-
payload.schemaVersion = SCHEMA_VERSION;
51-
write(run, payload);
95+
CachedValue entry = memCache.get(run.getExternalizableId(), k -> new CachedValue());
96+
synchronized (entry) {
97+
if (entry.graph == null) {
98+
entry.graph = readJson(treeFile(run), PipelineGraph.class);
99+
}
100+
if (entry.graph == null) {
101+
entry.graph = compute.get();
102+
writeJson(treeFile(run), entry.graph);
52103
}
53-
return payload.graph;
104+
return entry.graph;
54105
}
55106
}
56107

57108
public PipelineStepList getAllSteps(WorkflowRun run, Supplier<PipelineStepList> compute) {
58109
if (run.isBuilding()) {
59110
return compute.get();
60111
}
61-
CachedPayload payload = load(run);
62-
synchronized (payload) {
63-
if (payload.allSteps == null) {
64-
payload.allSteps = compute.get();
65-
payload.schemaVersion = SCHEMA_VERSION;
66-
write(run, payload);
112+
CachedValue entry = memCache.get(run.getExternalizableId(), k -> new CachedValue());
113+
synchronized (entry) {
114+
if (entry.allSteps == null) {
115+
entry.allSteps = readJson(allStepsFile(run), PipelineStepList.class);
67116
}
68-
return payload.allSteps;
117+
if (entry.allSteps == null) {
118+
entry.allSteps = compute.get();
119+
writeJson(allStepsFile(run), entry.allSteps);
120+
}
121+
return entry.allSteps;
69122
}
70123
}
71124

@@ -76,69 +129,77 @@ public PipelineStepList getAllSteps(WorkflowRun run, Supplier<PipelineStepList>
76129
* {@code WorkflowRun.isBuilding()} may not have flipped yet.
77130
*/
78131
public void seed(WorkflowRun run, PipelineGraph graph, PipelineStepList allSteps) {
79-
CachedPayload payload = load(run);
80-
synchronized (payload) {
81-
payload.graph = graph;
82-
payload.allSteps = allSteps;
83-
payload.schemaVersion = SCHEMA_VERSION;
84-
write(run, payload);
132+
CachedValue entry = memCache.get(run.getExternalizableId(), k -> new CachedValue());
133+
synchronized (entry) {
134+
entry.graph = graph;
135+
entry.allSteps = allSteps;
136+
writeJson(treeFile(run), graph);
137+
writeJson(allStepsFile(run), allSteps);
85138
}
86139
}
87140

88-
private CachedPayload load(WorkflowRun run) {
89-
return memCache.get(run.getExternalizableId(), k -> readFromDisk(run));
141+
/** Returns the JSON-decoded value at {@code source} or {@code null} if the file is absent or unreadable. */
142+
private <T> T readJson(Path source, Class<T> type) {
143+
if (!Files.exists(source)) {
144+
return null;
145+
}
146+
try (InputStream in = new BufferedInputStream(Files.newInputStream(source))) {
147+
return MAPPER.readValue(in, type);
148+
} catch (IOException e) {
149+
// A corrupt/older file shouldn't wedge the cache: drop it and fall back to compute.
150+
logger.warn("Failed to read pipeline graph cache for {}; recomputing", source, e);
151+
return null;
152+
}
90153
}
91154

92-
private CachedPayload readFromDisk(WorkflowRun run) {
93-
XmlFile file = cacheFile(run);
94-
if (!file.exists()) {
95-
return new CachedPayload();
155+
private void writeJson(Path target, Object data) {
156+
Path dir = target.getParent();
157+
if (dir == null) {
158+
throw new RuntimeException("No parent directory for " + target);
96159
}
160+
Path tmp = null;
97161
try {
98-
Object read = file.read();
99-
if (read instanceof CachedPayload loaded && loaded.schemaVersion == SCHEMA_VERSION) {
100-
return loaded;
162+
tmp = Files.createTempFile(dir, target.getFileName() + ".", ".tmp");
163+
try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(tmp))) {
164+
MAPPER.writeValue(os, data);
165+
}
166+
try {
167+
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
168+
} catch (AtomicMoveNotSupportedException e) {
169+
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
101170
}
102-
logger.debug("Discarding pipeline graph cache for {}: schema version mismatch", run.getExternalizableId());
171+
tmp = null;
172+
// Best-effort cleanup of any pre-v1 XStream cache left behind by older versions
173+
// of the plugin.
174+
Files.deleteIfExists(dir.resolve(LEGACY_XSTREAM_FILE_NAME));
103175
} catch (IOException e) {
104-
logger.warn("Failed to read pipeline graph cache for {}", run.getExternalizableId(), e);
176+
logger.warn("Failed to write pipeline graph cache for {}", target.getFileName(), e);
177+
} finally {
178+
if (tmp != null) {
179+
try {
180+
Files.deleteIfExists(tmp);
181+
} catch (IOException e) {
182+
logger.warn("Failed to delete temporary pipeline graph cache file", e);
183+
}
184+
}
105185
}
106-
return new CachedPayload();
107186
}
108187

109-
private void write(WorkflowRun run, CachedPayload payload) {
110-
try {
111-
cacheFile(run).write(payload);
112-
} catch (IOException e) {
113-
logger.warn("Failed to write pipeline graph cache for {}", run.getExternalizableId(), e);
114-
}
188+
private Path treeFile(WorkflowRun run) {
189+
return run.getRootDir().toPath().resolve(TREE_FILE_NAME);
115190
}
116191

117-
private XmlFile cacheFile(WorkflowRun run) {
118-
return new XmlFile(XSTREAM, new File(run.getRootDir(), CACHE_FILE_NAME));
192+
private Path allStepsFile(WorkflowRun run) {
193+
return run.getRootDir().toPath().resolve(ALL_STEPS_FILE_NAME);
119194
}
120195

121-
/** Test hook: drop in-memory entries so the next call goes through the disk read path. */
196+
/** Test hook: drop in-memory entries so the next call re-runs the supplier. */
122197
void invalidateMemory() {
123198
memCache.invalidateAll();
124199
}
125200

126-
static class CachedPayload {
127-
int schemaVersion;
201+
static class CachedValue {
128202
PipelineGraph graph;
129203
PipelineStepList allSteps;
130204
}
131-
132-
private static final XStream2 XSTREAM = new XStream2();
133-
134-
static {
135-
XSTREAM.alias("pipeline-graph-view-cache", CachedPayload.class);
136-
XSTREAM.alias("pipeline-graph", PipelineGraph.class);
137-
XSTREAM.alias("pipeline-stage", PipelineStage.class);
138-
XSTREAM.alias("pipeline-step", PipelineStep.class);
139-
XSTREAM.alias("pipeline-step-list", PipelineStepList.class);
140-
XSTREAM.alias("pipeline-input-step", PipelineInputStep.class);
141-
XSTREAM.alias("timing-info", TimingInfo.class);
142-
XSTREAM.alias("pipeline-state", PipelineState.class);
143-
}
144205
}

0 commit comments

Comments
 (0)