-
-
Notifications
You must be signed in to change notification settings - Fork 71
Make Pipeline Overview responsive on large in-progress builds #1225
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
Changes from 4 commits
a0d3a43
ecf93cf
d47ee6b
7e410c8
681d36f
b8464cd
23e72c5
2cc823d
964e9d8
ec01eff
a31dcfd
c4f02ef
3d728c7
87be5d3
be53e67
206a4f4
7986ec6
424b046
0df2ba5
a4cc8de
7bf0c63
d8369de
21f7ce3
cdb9eab
f05db98
a93df1f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package io.jenkins.plugins.pipelinegraphview.livestate; | ||
|
|
||
| import edu.umd.cs.findbugs.annotations.NonNull; | ||
| import hudson.Extension; | ||
| import org.jenkinsci.plugins.workflow.flow.FlowExecution; | ||
| import org.jenkinsci.plugins.workflow.flow.FlowExecutionListener; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| /** | ||
| * Creates {@link LiveGraphState} entries at execution start / resume and evicts them on | ||
| * completion. Without this, entries are still created lazily by {@link LiveGraphPopulator} | ||
| * on first {@code onNewHead}, but {@code onResumed} guarantees the catch-up scan happens | ||
| * once up-front rather than at the first event after restart. | ||
| */ | ||
| @Extension | ||
| public class LiveGraphLifecycle extends FlowExecutionListener { | ||
|
|
||
| private static final Logger logger = LoggerFactory.getLogger(LiveGraphLifecycle.class); | ||
|
|
||
| @Override | ||
| public void onRunning(@NonNull FlowExecution execution) { | ||
| try { | ||
| LiveGraphRegistry.get().getOrCreate(execution); | ||
| } catch (Throwable t) { | ||
| logger.warn("pipeline-graph-view live state onRunning failed", t); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void onResumed(@NonNull FlowExecution execution) { | ||
| try { | ||
| LiveGraphState state = LiveGraphRegistry.get().getOrCreate(execution); | ||
| if (state != null) { | ||
| LiveGraphPopulator.catchUp(execution, state); | ||
| } | ||
| } catch (Throwable t) { | ||
| logger.warn("pipeline-graph-view live state onResumed failed", t); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void onCompleted(@NonNull FlowExecution execution) { | ||
| try { | ||
| LiveGraphRegistry.get().remove(execution); | ||
| } catch (Throwable t) { | ||
| logger.warn("pipeline-graph-view live state onCompleted failed", t); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| 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}. The downstream {@code PipelineGraphApi} | ||
| * path reads a snapshot of that state instead of walking the whole execution each time. | ||
| * | ||
| * <p>We use {@link GraphListener.Synchronous} rather than the async variant because callers | ||
| * expect "once a node is a head, the next API read reflects it" — async delivery creates a | ||
| * lag window where the snapshot is behind the execution, which breaks tests that check state | ||
| * at precise trigger points and would surprise anyone hitting the REST API after an event. | ||
| * The work done under the monitor is trivial ({@code ArrayList}/{@code HashSet} additions), | ||
| * so the CPS VM thread is not meaningfully blocked. Every code path is still wrapped in | ||
| * try/catch and poisons the state on failure so a bug here can never disrupt a build. | ||
| */ | ||
| @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 | ||
| } | ||
| // Lazy initial catch-up: if the listener is seeing nodes for an execution it's | ||
| // never observed (plugin upgrade mid-build, Jenkins resume without onResumed | ||
| // firing first), the early history is already in the FlowExecution's storage. | ||
| // Backfill it once before processing this event. | ||
| if (state.size() == 0 && !state.hasSeen(node.getId())) { | ||
| catchUp(execution, state); | ||
| } | ||
| 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("pipeline-graph-view live state failed; falling back to scanner", t); | ||
| if (state != null) { | ||
| state.poison(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| static void catchUp(FlowExecution execution, LiveGraphState state) { | ||
| try { | ||
| DepthFirstScanner scanner = new DepthFirstScanner(); | ||
| scanner.setup(execution.getCurrentHeads()); | ||
| for (FlowNode existing : scanner) { | ||
| state.addNode(existing); | ||
| } | ||
| } catch (Throwable t) { | ||
| logger.warn("pipeline-graph-view live state catch-up failed; poisoning", t); | ||
| state.poison(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| 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
|
||
|
|
||
| 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); | ||
| } | ||
|
timja marked this conversation as resolved.
Outdated
|
||
|
|
||
| LiveGraphState getOrCreate(FlowExecution execution) { | ||
| if (disabled()) { | ||
| return null; | ||
| } | ||
| String key = keyFor(execution); | ||
| if (key == null) { | ||
| return null; | ||
| } | ||
| 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) { | ||
| 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()) { | ||
| return null; | ||
| } | ||
| LiveGraphState state = states.getIfPresent(run.getExternalizableId()); | ||
| return state == null ? null : state.cachedGraph(minVersion); | ||
| } | ||
|
|
||
| /** Stores a {@link PipelineGraph} computed from a snapshot at {@code version}. */ | ||
| public void cacheGraph(WorkflowRun run, long version, PipelineGraph graph) { | ||
| if (disabled()) { | ||
| return; | ||
| } | ||
| LiveGraphState state = states.getIfPresent(run.getExternalizableId()); | ||
| if (state != null) { | ||
| state.cacheGraph(version, graph); | ||
| } | ||
| } | ||
|
|
||
| public PipelineStepList cachedAllSteps(WorkflowRun run, long minVersion) { | ||
| if (disabled()) { | ||
| return null; | ||
| } | ||
| LiveGraphState state = states.getIfPresent(run.getExternalizableId()); | ||
| return state == null ? null : state.cachedAllSteps(minVersion); | ||
| } | ||
|
|
||
| public void cacheAllSteps(WorkflowRun run, long version, PipelineStepList steps) { | ||
| if (disabled()) { | ||
| return; | ||
| } | ||
| LiveGraphState state = states.getIfPresent(run.getExternalizableId()); | ||
| if (state != null) { | ||
| state.cacheAllSteps(version, steps); | ||
| } | ||
| } | ||
|
|
||
| private static String keyFor(FlowExecution execution) { | ||
| try { | ||
| Object exec = execution.getOwner().getExecutable(); | ||
| if (exec instanceof WorkflowRun run) { | ||
| return run.getExternalizableId(); | ||
| } | ||
| return null; | ||
| } catch (Exception e) { | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| 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. | ||
| * Held briefly outside the state's monitor so HTTP callers can construct DTOs without | ||
| * blocking the CPS VM thread that's feeding the live state. | ||
| * | ||
| * <p>{@code version} is a monotonically-increasing counter that bumps on every new flow | ||
| * node. It lets HTTP callers key the output cache so repeat polls between node arrivals | ||
| * return the cached {@code PipelineGraph} / {@code PipelineStepList} without rebuilding. | ||
| */ | ||
| public record LiveGraphSnapshot(List<FlowNode> nodes, List<FlowNode> workspaceNodes, long version) {} |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,99 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| package io.jenkins.plugins.pipelinegraphview.livestate; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraph; | ||||||||||||||||||||||||||||||||||||||||||||||
| import io.jenkins.plugins.pipelinegraphview.utils.PipelineStepList; | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.ArrayList; | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.HashSet; | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Set; | ||||||||||||||||||||||||||||||||||||||||||||||
| import org.jenkinsci.plugins.workflow.actions.WorkspaceAction; | ||||||||||||||||||||||||||||||||||||||||||||||
| import org.jenkinsci.plugins.workflow.graph.FlowNode; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||
| * Per-run mutable state built up by {@link LiveGraphPopulator} as {@code GraphListener} | ||||||||||||||||||||||||||||||||||||||||||||||
| * events arrive. Reads and writes are serialised on the instance monitor; holders should | ||||||||||||||||||||||||||||||||||||||||||||||
| * snapshot and release quickly — the writer is the CPS VM thread and must not block. | ||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||
| * <p>Two things live here: | ||||||||||||||||||||||||||||||||||||||||||||||
| * <ul> | ||||||||||||||||||||||||||||||||||||||||||||||
| * <li>The raw {@link FlowNode} list (plus the {@link WorkspaceAction}-carrying subset) | ||||||||||||||||||||||||||||||||||||||||||||||
| * that the downstream relationship-finder / graph-builder path consumes.</li> | ||||||||||||||||||||||||||||||||||||||||||||||
| * <li>A small cache of the last-computed {@link PipelineGraph} / {@link PipelineStepList} | ||||||||||||||||||||||||||||||||||||||||||||||
| * keyed by a monotonic version counter that bumps on every {@link #addNode}. HTTP | ||||||||||||||||||||||||||||||||||||||||||||||
| * polls that hit between node arrivals return the cached DTO verbatim — no rebuild.</li> | ||||||||||||||||||||||||||||||||||||||||||||||
| * </ul> | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
| final class LiveGraphState { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private final List<FlowNode> nodes = new ArrayList<>(); | ||||||||||||||||||||||||||||||||||||||||||||||
| private final Set<String> seenIds = new HashSet<>(); | ||||||||||||||||||||||||||||||||||||||||||||||
| private final List<FlowNode> workspaceNodes = new ArrayList<>(); | ||||||||||||||||||||||||||||||||||||||||||||||
| private long version = 0; | ||||||||||||||||||||||||||||||||||||||||||||||
| private volatile boolean poisoned = false; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Output cache. Volatile reference to an immutable (version, value) tuple so readers | ||||||||||||||||||||||||||||||||||||||||||||||
| // and writers never see a torn pair. | ||||||||||||||||||||||||||||||||||||||||||||||
| private volatile VersionedCache<PipelineGraph> cachedGraph; | ||||||||||||||||||||||||||||||||||||||||||||||
| private volatile VersionedCache<PipelineStepList> cachedAllSteps; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| synchronized void addNode(FlowNode node) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!seenIds.add(node.getId())) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| nodes.add(node); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (node.getAction(WorkspaceAction.class) != null) { | ||||||||||||||||||||||||||||||||||||||||||||||
| workspaceNodes.add(node); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| version++; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| synchronized boolean hasSeen(String nodeId) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return seenIds.contains(nodeId); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| synchronized int size() { | ||||||||||||||||||||||||||||||||||||||||||||||
| return nodes.size(); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| synchronized LiveGraphSnapshot snapshot() { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (poisoned) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| return new LiveGraphSnapshot(List.copyOf(nodes), List.copyOf(workspaceNodes), version); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||
| * Returns the cached graph if it was computed at or after {@code minVersion}. Returning | ||||||||||||||||||||||||||||||||||||||||||||||
| * a newer cache than requested is intentional — the caller's snapshot can only become | ||||||||||||||||||||||||||||||||||||||||||||||
| * staler, so a newer output is strictly more accurate. | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
| PipelineGraph cachedGraph(long minVersion) { | ||||||||||||||||||||||||||||||||||||||||||||||
| VersionedCache<PipelineGraph> cached = cachedGraph; | ||||||||||||||||||||||||||||||||||||||||||||||
| return (cached != null && cached.version >= minVersion) ? cached.value : null; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| void cacheGraph(long version, PipelineGraph graph) { | ||||||||||||||||||||||||||||||||||||||||||||||
| VersionedCache<PipelineGraph> current = cachedGraph; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (current == null || current.version < version) { | ||||||||||||||||||||||||||||||||||||||||||||||
| cachedGraph = new VersionedCache<>(version, graph); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| PipelineStepList cachedAllSteps(long minVersion) { | ||||||||||||||||||||||||||||||||||||||||||||||
| VersionedCache<PipelineStepList> cached = cachedAllSteps; | ||||||||||||||||||||||||||||||||||||||||||||||
| return (cached != null && cached.version >= minVersion) ? cached.value : null; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| void cacheAllSteps(long version, PipelineStepList steps) { | ||||||||||||||||||||||||||||||||||||||||||||||
| VersionedCache<PipelineStepList> current = cachedAllSteps; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (current == null || current.version < version) { | ||||||||||||||||||||||||||||||||||||||||||||||
| cachedAllSteps = new VersionedCache<>(version, steps); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+150
to
+158
|
||||||||||||||||||||||||||||||||||||||||||||||
| return (cachedAllSteps != null && cachedAllSteps.version >= minVersion) ? cachedAllSteps.value : null; | |
| } | |
| synchronized void cacheAllSteps(long version, PipelineStepList steps) { | |
| if (cachedAllSteps == null || cachedAllSteps.version < version) { | |
| cachedAllSteps = new VersionedCache<>(version, steps); | |
| } | |
| } | |
| return (cachedAllSteps != null && cachedAllSteps.version >= minVersion) | |
| ? copyPipelineStepList(cachedAllSteps.value) | |
| : null; | |
| } | |
| synchronized void cacheAllSteps(long version, PipelineStepList steps) { | |
| if (cachedAllSteps == null || cachedAllSteps.version < version) { | |
| cachedAllSteps = new VersionedCache<>(version, copyPipelineStepList(steps)); | |
| } | |
| } | |
| private PipelineStepList copyPipelineStepList(PipelineStepList steps) { | |
| return new PipelineStepList(List.copyOf(steps.steps)); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LiveGraphState.snapshot() assumes
nodesinsertion order is chronological (oldest→newest) and reverses it to approximateDepthFirstScanner(newest-first). In catchUp(), you iterateDepthFirstScannerdirectly and callstate.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, soPipelineGraphApi#getStageNodemay 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 liveonNewHeadpath.