11package io .jenkins .plugins .pipelinegraphview .utils ;
22
3+ import com .fasterxml .jackson .annotation .JsonAutoDetect ;
4+ import com .fasterxml .jackson .annotation .JsonInclude ;
35import com .github .benmanes .caffeine .cache .Cache ;
46import 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 ;
99import 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 ;
1017import java .util .function .Supplier ;
1118import org .jenkinsci .plugins .workflow .job .WorkflowRun ;
1219import org .slf4j .Logger ;
1320import 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 */
2138public 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