Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package io.jenkins.plugins.pipelinegraphview.livestate;

import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Extension;
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraph;
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraphApi;
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraphViewCache;
import io.jenkins.plugins.pipelinegraphview.utils.PipelineStepApi;
import io.jenkins.plugins.pipelinegraphview.utils.PipelineStepList;
import org.jenkinsci.plugins.workflow.flow.FlowExecution;
import org.jenkinsci.plugins.workflow.flow.FlowExecutionListener;
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Creates and readies {@link LiveGraphState} entries at execution start / resume, and
* hands the final graph off to the on-disk cache at completion so the first post-build
* read doesn't need a fresh scanner sweep.
*/
@Extension
public class LiveGraphLifecycle extends FlowExecutionListener {

private static final Logger logger = LoggerFactory.getLogger(LiveGraphLifecycle.class);

@Override
public void onRunning(@NonNull FlowExecution execution) {
try {
// Fresh execution — no prior nodes to catch up.
LiveGraphState state = LiveGraphRegistry.get().getOrCreate(execution);
if (state != null) {
state.markReady();
}
} catch (Throwable t) {
logger.warn("onRunning failed", t);

Check warning on line 35 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphLifecycle.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 34-35 are not covered by tests
}
}

@Override
public void onResumed(@NonNull FlowExecution execution) {
try {
// Resumed after a Jenkins restart: the execution's persisted graph already holds
// nodes we never saw live. Running here (not on the CPS VM) makes a scanner walk
// safe.
LiveGraphState state = LiveGraphRegistry.get().getOrCreate(execution);
if (state != null) {
LiveGraphPopulator.catchUp(execution, state);
state.markReady();
}
} catch (Throwable t) {
logger.warn("onResumed failed", t);
}
}

Check warning on line 53 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphLifecycle.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 45-53 are not covered by tests

@Override
public void onCompleted(@NonNull FlowExecution execution) {
try {
WorkflowRun run = workflowRunFor(execution);
if (run != null) {

Check warning on line 59 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphLifecycle.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 59 is only partially covered, one branch is missing
PipelineGraph graph = new PipelineGraphApi(run).computeTree();
PipelineStepList allSteps = new PipelineStepApi(run).computeAllSteps();
// WorkflowRun.isBuilding() can still be true here even though FlowExecution
// is complete; rebuild with runIsComplete=true so the persisted copy matches
// reality. PipelineGraph.complete already reflects FlowExecution.isComplete().
PipelineStepList finalSteps = new PipelineStepList(allSteps.steps, true);
PipelineGraphViewCache.get().seed(run, graph, finalSteps);
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

In onCompleted(), finalSteps reuses the same mutable List instance via new PipelineStepList(allSteps.steps, true). Since PipelineStepList.steps is publicly mutable, this aliases the list between the cached in-memory DTO (potentially concurrently read) and the persisted payload being written to disk. Prefer copying the list (or adding a constructor that defensively copies) when creating the “final” persisted version.

Copilot uses AI. Check for mistakes.
}
Comment on lines +58 to +89
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

onCompleted now computes and writes the graph/steps for every finished run (including when the live-state feature flag is disabled, since snapshot() will be null and the code falls back to a full scanner sweep). This can add noticeable CPU and I/O to build finalization even if nobody ever requests the view. Consider honoring the .enabled flag here (or adding a separate knob for seeding), and/or short-circuiting if the on-disk cache file already exists and matches the current schema.

Copilot uses AI. Check for mistakes.
} catch (Throwable t) {
logger.warn("seeding disk cache on completion failed", t);

Check warning on line 69 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphLifecycle.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 68-69 are not covered by tests
} finally {
try {
LiveGraphRegistry.get().remove(execution);
} catch (Throwable t) {
logger.warn("state eviction on completion failed", t);

Check warning on line 74 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphLifecycle.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 73-74 are not covered by tests
}
}
}

private static WorkflowRun workflowRunFor(FlowExecution execution) {
try {
Object exec = execution.getOwner().getExecutable();
return exec instanceof WorkflowRun r ? r : null;

Check warning on line 82 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphLifecycle.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 82 is only partially covered, one branch is missing
} catch (Exception e) {
return null;

Check warning on line 84 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphLifecycle.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 83-84 are not covered by tests
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package io.jenkins.plugins.pipelinegraphview.livestate;

import hudson.Extension;
import org.jenkinsci.plugins.workflow.flow.FlowExecution;
import org.jenkinsci.plugins.workflow.flow.GraphListener;
import org.jenkinsci.plugins.workflow.graph.FlowNode;
import org.jenkinsci.plugins.workflow.graphanalysis.DepthFirstScanner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Extension that captures every new {@link FlowNode} across every running execution and
* feeds it to the corresponding {@link LiveGraphState}.
*
* <p>We use {@link GraphListener.Synchronous} because callers expect that once a node is a
* head, the next API read reflects it — async delivery would create a lag window where the
* snapshot is behind the execution. The listener therefore does the minimum under the
* monitor and never scans: any catch-up belongs in {@link LiveGraphLifecycle}, on a Jenkins
* event thread.
*/
@Extension
public class LiveGraphPopulator implements GraphListener.Synchronous {

private static final Logger logger = LoggerFactory.getLogger(LiveGraphPopulator.class);

@Override
public void onNewHead(FlowNode node) {
LiveGraphState state = null;
try {
FlowExecution execution = node.getExecution();
state = LiveGraphRegistry.get().getOrCreate(execution);
if (state == null) {
return; // feature disabled or execution not a WorkflowRun
}
state.addNode(node);
} catch (Throwable t) {
// A thrown exception here propagates into the CPS VM and can abort the build.
// Poison the state so subsequent reads fall back to the scanner; log the failure
// but never rethrow.
logger.warn("live state failed; falling back to scanner", t);
if (state != null) {
state.poison();

Check warning on line 42 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphPopulator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 36-42 are not covered by tests
}
}
}

/**
* Backfills the live state from the execution's persisted graph. Called only from
* {@link LiveGraphLifecycle#onResumed}, which runs on a Jenkins event thread — never
* from a {@link GraphListener.Synchronous} path on the CPS VM.
*/
static void catchUp(FlowExecution execution, LiveGraphState state) {
try {
DepthFirstScanner scanner = new DepthFirstScanner();
scanner.setup(execution.getCurrentHeads());
for (FlowNode existing : scanner) {
state.addNode(existing);
}
Comment on lines +54 to +68
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

LiveGraphState.snapshot() assumes nodes insertion order is chronological (oldest→newest) and reverses it to approximate DepthFirstScanner (newest-first). In catchUp(), you iterate DepthFirstScanner directly and call state.addNode(existing) in that iteration order, which is already newest-first; after a restart this makes the subsequent reverse-scan produce oldest-first workspace candidates, so PipelineGraphApi#getStageNode may pick an outer workspace instead of the most-specific one. Consider collecting scanner output and adding nodes to the state in reverse (oldest→newest) to keep insertion order consistent with the live onNewHead path.

Copilot uses AI. Check for mistakes.
} catch (Throwable t) {
logger.warn("catch-up failed; poisoning state", t);
state.poison();
}
}

Check warning on line 63 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphPopulator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 54-63 are not covered by tests
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package io.jenkins.plugins.pipelinegraphview.livestate;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraph;
import io.jenkins.plugins.pipelinegraphview.utils.PipelineStepList;
import java.time.Duration;
import jenkins.util.SystemProperties;
import org.jenkinsci.plugins.workflow.flow.FlowExecution;
import org.jenkinsci.plugins.workflow.job.WorkflowRun;

/**
* Singleton holding one {@link LiveGraphState} per in-progress run.
* Entries are created on demand by the listener / lifecycle code, removed on completion,
* and otherwise bounded by a Caffeine LRU so abandoned entries (deleted runs, listener
* bugs) don't leak.
*/
public final class LiveGraphRegistry {

// IMPORTANT: keep CACHE_MAX_SIZE declared BEFORE INSTANCE. Static fields initialise in
// source order, and the instance's Caffeine builder reads CACHE_MAX_SIZE — if INSTANCE
// were declared first, CACHE_MAX_SIZE would still be 0 during construction and Caffeine
// would evict every entry immediately (maximumSize(0) means "no entries allowed").
private static final int CACHE_MAX_SIZE =
SystemProperties.getInteger(LiveGraphRegistry.class.getName() + ".size", 512);

private static final LiveGraphRegistry INSTANCE = new LiveGraphRegistry();

public static LiveGraphRegistry get() {
return INSTANCE;
}

private final Cache<String, LiveGraphState> states = Caffeine.newBuilder()
.maximumSize(CACHE_MAX_SIZE)
.expireAfterAccess(Duration.ofMinutes(30))
.build();
Comment on lines +41 to +44
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

expireAfterAccess(30m) can evict a live state while a run is still in progress (e.g. long input/sleep/semaphore with no new heads and no API reads). If that happens, the next onNewHead recreates a fresh LiveGraphState that never becomes ready (since onRunning/onResumed won't fire again), so the live-state path stays permanently disabled for that run. Consider removing the TTL for in-progress runs, switching to a much longer expireAfterWrite, or ensuring recreated states are marked ready (possibly with a safe catch-up) so eviction doesn't break functionality.

Copilot uses AI. Check for mistakes.

LiveGraphRegistry() {}

/**
* Escape hatch. Setting this system property to {@code false} makes
* {@link #snapshot(WorkflowRun)} always return {@code null}, forcing callers to use the
* scanner fallback. Useful if a regression lands in the live-state path.
*/
private static boolean disabled() {
return !SystemProperties.getBoolean(LiveGraphRegistry.class.getName() + ".enabled", true);
}
Comment thread
timja marked this conversation as resolved.
Outdated

LiveGraphState getOrCreate(FlowExecution execution) {
if (disabled()) {
return null;
}
String key = keyFor(execution);
if (key == null) {

Check warning on line 54 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 54 is only partially covered, one branch is missing
return null;

Check warning on line 55 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 55 is not covered by tests
}
return states.get(key, k -> new LiveGraphState());
}

/**
* Returns a snapshot of the live state for this run, or {@code null} if none exists
* (feature disabled, state never populated, state poisoned). Callers must treat
* {@code null} as "fall back to the scanner path."
*/
public LiveGraphSnapshot snapshot(WorkflowRun run) {
if (disabled()) {
return null;
}
LiveGraphState state = states.getIfPresent(run.getExternalizableId());
return state == null ? null : state.snapshot();
}

void remove(FlowExecution execution) {
String key = keyFor(execution);
if (key != null) {

Check warning on line 75 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 75 is only partially covered, one branch is missing
states.invalidate(key);
}
}

/**
* Returns a previously-cached {@link PipelineGraph} for this run if it was computed at
* or after {@code minVersion}, otherwise {@code null}. Use {@link LiveGraphSnapshot#version()}
* as the argument — cache entries older than the caller's snapshot are rejected.
*/
public PipelineGraph cachedGraph(WorkflowRun run, long minVersion) {
if (disabled()) {

Check warning on line 86 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 86 is only partially covered, one branch is missing
return null;

Check warning on line 87 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 87 is not covered by tests
}
LiveGraphState state = states.getIfPresent(run.getExternalizableId());
return state == null ? null : state.cachedGraph(minVersion);

Check warning on line 90 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 90 is only partially covered, one branch is missing
}

public void cacheGraph(WorkflowRun run, long version, PipelineGraph graph) {
if (disabled()) {

Check warning on line 94 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 94 is only partially covered, one branch is missing
return;

Check warning on line 95 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 95 is not covered by tests
}
LiveGraphState state = states.getIfPresent(run.getExternalizableId());
if (state != null) {

Check warning on line 98 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 98 is only partially covered, one branch is missing
state.cacheGraph(version, graph);
}
}

public PipelineStepList cachedAllSteps(WorkflowRun run, long minVersion) {
if (disabled()) {

Check warning on line 104 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 104 is only partially covered, one branch is missing
return null;

Check warning on line 105 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 105 is not covered by tests
}
LiveGraphState state = states.getIfPresent(run.getExternalizableId());
return state == null ? null : state.cachedAllSteps(minVersion);

Check warning on line 108 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 108 is only partially covered, one branch is missing
}

public void cacheAllSteps(WorkflowRun run, long version, PipelineStepList steps) {
if (disabled()) {

Check warning on line 112 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 112 is only partially covered, one branch is missing
return;

Check warning on line 113 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 113 is not covered by tests
}
LiveGraphState state = states.getIfPresent(run.getExternalizableId());
if (state != null) {

Check warning on line 116 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 116 is only partially covered, one branch is missing
state.cacheAllSteps(version, steps);
}
}

private static String keyFor(FlowExecution execution) {
try {
Object exec = execution.getOwner().getExecutable();
if (exec instanceof WorkflowRun run) {

Check warning on line 124 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 124 is only partially covered, one branch is missing
return run.getExternalizableId();
}
return null;
} catch (Exception e) {
return null;

Check warning on line 129 in src/main/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphRegistry.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 127-129 are not covered by tests
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.jenkins.plugins.pipelinegraphview.livestate;

import java.util.List;
import org.jenkinsci.plugins.workflow.graph.FlowNode;

/**
* Immutable projection of a {@link LiveGraphState} at a point in time. {@code version} is
* a monotonic counter that bumps on every new flow node, so callers can use it as a cache
* key for computed DTOs.
*/
public record LiveGraphSnapshot(List<FlowNode> nodes, List<FlowNode> workspaceNodes, long version) {}
Loading
Loading