Skip to content

Commit 18bfb0b

Browse files
MaxKlessclaude
andauthored
fix(maven): write output after each task in batch mode to ensure correct files are cached (#34400)
## Current Behavior When running in maven 4 batch mode, the build state is recorded only after the full batch is done. This means that nx caching records the state of a task before build state is recorded to disk. When running another maven task that depends on this partially recorded cache, the build state file is missing and we get errors. ## Expected Behavior build state should be recorded after every task is done and before nx caching can kick in. This way we can ensure that nx cache is correct. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3b0fb81 commit 18bfb0b

File tree

6 files changed

+119
-52
lines changed

6 files changed

+119
-52
lines changed

e2e/maven/src/maven-batch-v4.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,26 @@ wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-w
4848
);
4949
});
5050

51+
it('should install successfully after restoring cached package outputs', () => {
52+
// Step 1: Clean target directories to simulate a clean CI checkout
53+
runCLI('run-many -t clean');
54+
55+
// Step 2: Run package in batch mode — cache hit restores outputs (including nx-build-state.json)
56+
runBatchCLI('run-many -t package');
57+
checkFilesExist(
58+
'app/target/app-1.0.0-SNAPSHOT.jar',
59+
'lib/target/lib-1.0.0-SNAPSHOT.jar',
60+
'utils/target/utils-1.0.0-SNAPSHOT.jar'
61+
);
62+
63+
// Step 3: Run install in batch mode — this requires build state from the package phase
64+
// to know about the main artifact.
65+
runBatchCLI('run-many -t install');
66+
});
67+
5168
it('should fail when unit test fails', () => {
69+
// TODO: remove once batch mode dependentTaskOutputs is fixed
70+
runCLI('reset');
5271
// Add a failing unit test
5372
updateFile(
5473
'app/src/test/java/com/example/app/AppApplicationTests.java',

packages/maven/batch-runner-adapters/maven3/src/main/kotlin/dev/nx/maven/adapter/maven3/NxMaven3.kt

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class NxMaven3(
3434
@Volatile private var cachedProjectGraph: ProjectDependencyGraph? = null
3535
@Volatile private var cachedRepositorySession: RepositorySystemSession? = null
3636

37+
/** Indexed lookup: "groupId:artifactId" → MavenProject. Built once in setupGraphCache(). */
38+
@Volatile private var projectBySelector: Map<String, MavenProject> = emptyMap()
39+
3740
init {
3841
if (System.getProperty("org.slf4j.simpleLogger.defaultLogLevel") == null) {
3942
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "info")
@@ -93,6 +96,7 @@ class NxMaven3(
9396
return
9497
}
9598
cachedProjectGraph = graph
99+
projectBySelector = graph.allProjects.associateBy { "${it.groupId}:${it.artifactId}" }
96100

97101
session.projects = graph.sortedProjects
98102
session.allProjects = graph.allProjects
@@ -168,11 +172,9 @@ class NxMaven3(
168172
session.projectDependencyGraph = graph
169173

170174
val selectedProjects = if (request.selectedProjects.isNotEmpty()) {
171-
graph.allProjects.filter { project ->
172-
request.selectedProjects.any { selector ->
173-
"${project.groupId}:${project.artifactId}" == selector ||
174-
project.artifactId == selector
175-
}
175+
request.selectedProjects.mapNotNull { selector ->
176+
projectBySelector[selector]
177+
?: graph.allProjects.find { it.artifactId == selector }
176178
}
177179
} else {
178180
graph.allProjects.filter { it.file?.absolutePath == request.pom?.absolutePath }
@@ -190,14 +192,13 @@ class NxMaven3(
190192
}
191193

192194
fun recordBuildStates(projectSelectors: Set<String>) {
193-
val graph = cachedProjectGraph ?: return
195+
if (cachedProjectGraph == null) return
194196

195197
projectSelectors.forEach { selector ->
196-
graph.allProjects.find { "${it.groupId}:${it.artifactId}" == selector }
197-
?.let { project ->
198-
try { BuildStateManager.recordBuildState(project) }
199-
catch (_: Exception) { }
200-
}
198+
projectBySelector[selector]?.let { project ->
199+
try { BuildStateManager.recordBuildState(project) }
200+
catch (_: Exception) { }
201+
}
201202
}
202203
}
203204
}

packages/maven/batch-runner-adapters/maven4/src/main/kotlin/dev/nx/maven/adapter/maven4/NxMaven.kt

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ class NxMaven(
6666
private val executionCount = AtomicInteger(0)
6767

6868
@Volatile
69-
private var cachedProjectGraph: ProjectDependencyGraph? = null // ProjectDependencyGraph
69+
private var cachedProjectGraph: ProjectDependencyGraph? = null
70+
71+
/** Indexed lookup: "groupId:artifactId" → MavenProject. Built once in setupGraphCache(). */
72+
@Volatile
73+
private var projectBySelector: Map<String, MavenProject> = emptyMap()
7074

7175
@Volatile
7276
private var cachedRepositorySession: RepositorySystemSession? = null
@@ -167,6 +171,7 @@ class NxMaven(
167171

168172
val graph = graphResult.get()
169173
cachedProjectGraph = graph
174+
projectBySelector = graph.allProjects.associateBy { "${it.groupId}:${it.artifactId}" }
170175

171176
log.debug(" ✅ Graph cache setup complete with ${graph.allProjects.size} projects")
172177
graph.sortedProjects?.forEach { project ->
@@ -196,9 +201,9 @@ class NxMaven(
196201
session.projectDependencyGraph = graph
197202

198203
// Find the selected project(s) to build
199-
val selectedProjects = session.allProjects.filter {
200-
"${it.groupId}:${it.artifactId}" == request.selectedProjects.firstOrNull()
201-
}
204+
val selectedProjects = listOfNotNull(
205+
request.selectedProjects.firstOrNull()?.let { projectBySelector[it] }
206+
)
202207

203208
// session.projects controls what lifecycleStarter builds - keep it to selected projects only
204209
session.projects = selectedProjects
@@ -355,10 +360,7 @@ class NxMaven(
355360
var failedCount = 0
356361

357362
projectSelectors.forEach { selector ->
358-
// Find matching project in the cached graph
359-
val project = cachedProjectGraph!!.allProjects.find { p ->
360-
"${p.groupId}:${p.artifactId}" == selector
361-
}
363+
val project = projectBySelector[selector]
362364

363365
if (project != null) {
364366
try {

packages/maven/batch-runner/src/main/kotlin/dev/nx/maven/runner/MavenInvokerRunner.kt

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ class MavenInvokerRunner(private val workspaceRoot: File, private val options: M
8585

8686
val result = executeSingleTask(taskId, results)
8787

88+
// Record build state for all batch projects BEFORE emitting result.
89+
// emitResult() triggers Nx to cache task outputs, so nx-build-state.json
90+
// must be written first to ensure the cached outputs include fresh build state.
91+
recordBuildStatesForBatchProjects(taskId)
92+
8893
// Emit result to stderr for streaming to Nx
8994
emitResult(taskId, result)
9095

@@ -132,9 +137,6 @@ class MavenInvokerRunner(private val workspaceRoot: File, private val options: M
132137

133138
// Wait for all tasks to complete
134139
completionLatch.await()
135-
136-
// Record build states for all projects that had tasks executed
137-
recordBuildStatesForExecutedTasks()
138140
} finally {
139141
// Threads are daemon threads, so they won't prevent JVM exit
140142
// Just try to shutdown gracefully without waiting
@@ -149,28 +151,29 @@ class MavenInvokerRunner(private val workspaceRoot: File, private val options: M
149151
return results.toMap()
150152
}
151153

154+
/** All unique project selectors in the batch, computed once. */
155+
private val allBatchProjectSelectors: Set<String> by lazy {
156+
options.taskGraph?.tasks?.values
157+
?.map { it.target.project }
158+
?.toSet() ?: emptySet()
159+
}
160+
152161
/**
153-
* Record build states for all unique projects that had tasks executed.
154-
* This is called after all tasks complete to save the build state for future batches.
162+
* Record build state for all projects in the batch.
163+
* Called after each task execution but before emitting the result to Nx,
164+
* ensuring nx-build-state.json is up-to-date when Nx caches the task's outputs.
165+
*
166+
* We record ALL batch projects (not just the current task's project) because
167+
* a task in projectA can modify the Maven session state of projectB
168+
* (e.g., adding source roots or classpath entries). The lastWrittenState cache
169+
* in BuildStateRecorder ensures we only perform file I/O for projects whose
170+
* state actually changed.
155171
*/
156-
private fun recordBuildStatesForExecutedTasks() {
172+
private fun recordBuildStatesForBatchProjects(taskId: String) {
157173
try {
158-
// Extract unique project selectors from executed tasks
159-
val uniqueProjectSelectors = options.taskGraph?.tasks?.values
160-
?.map { it.target.project }
161-
?.toSet() ?: emptySet()
162-
163-
if (uniqueProjectSelectors.isEmpty()) {
164-
log.debug("No projects to record build states for")
165-
return
166-
}
167-
168-
log.debug("Preparing to record build states for ${uniqueProjectSelectors.size} unique projects")
169-
log.debug("Projects: ${uniqueProjectSelectors.joinToString(", ")}")
170-
171-
(mavenExecutor as? ResidentMavenExecutor)?.recordBuildStates(uniqueProjectSelectors)
174+
(mavenExecutor as? ResidentMavenExecutor)?.recordBuildStates(allBatchProjectSelectors)
172175
} catch (e: Exception) {
173-
log.error("Error recording build states: ${e.message}", e)
176+
log.error("Error recording build states after task $taskId: ${e.message}", e)
174177
}
175178
}
176179

packages/maven/shared/src/main/kotlin/dev/nx/maven/adapter/AdapterInvoker.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ interface AdapterInvoker : AutoCloseable {
3030

3131
/**
3232
* Record build states for the specified projects.
33-
* Called after batch execution to save state for future runs.
33+
* Called after each task completes (for all batch projects) to save state
34+
* before Nx caches outputs. Cross-project state mutations are captured
35+
* because all batch projects are recorded, not just the current task's project.
3436
*
3537
* @param projectSelectors Project selectors (e.g., "groupId:artifactId")
3638
*/

packages/maven/shared/src/main/kotlin/dev/nx/maven/shared/BuildStateRecorder.kt

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import org.apache.maven.project.MavenProject
77
import org.slf4j.Logger
88
import org.slf4j.LoggerFactory
99
import java.io.File
10+
import java.util.concurrent.ConcurrentHashMap
1011

1112
/**
1213
* Utility for recording build state from Maven projects.
@@ -16,39 +17,69 @@ object BuildStateRecorder {
1617
private val log: Logger = LoggerFactory.getLogger(BuildStateRecorder::class.java)
1718
private val gson = GsonBuilder().setPrettyPrinting().create()
1819

20+
/**
21+
* Cache of the last-written BuildState per project (keyed by "groupId:artifactId").
22+
* Used to skip redundant JSON serialization and file I/O when the state hasn't changed.
23+
*/
24+
private val lastWrittenState = ConcurrentHashMap<String, BuildState>()
25+
26+
/**
27+
* Cache of canonicalized path mappings to avoid repeated filesystem syscalls.
28+
* Key: absolute path, Value: relative path from project root.
29+
* File.canonicalPath is a syscall that resolves symlinks — caching it avoids
30+
* hundreds of redundant syscalls per recording (especially for classpath entries
31+
* pointing to ~/.m2/repository).
32+
*/
33+
private val canonicalPathCache = ConcurrentHashMap<Pair<String, String>, String>()
34+
35+
private fun toRelativePathCached(absolutePath: String, projectRoot: File): String {
36+
val rootPath = projectRoot.absolutePath
37+
val key = Pair(absolutePath, rootPath)
38+
return canonicalPathCache.getOrPut(key) {
39+
PathUtils.toRelativePath(absolutePath, projectRoot, log)
40+
}
41+
}
42+
43+
private fun toRelativePathsCached(absolutePaths: Set<String>, projectRoot: File): Set<String> {
44+
return absolutePaths.map { toRelativePathCached(it, projectRoot) }.toSet()
45+
}
46+
1947
/**
2048
* Record the build state of a Maven project to nx-build-state.json.
49+
* Computes the full state every time for correctness, but skips the file write
50+
* if the state is identical to what was last written.
2151
*
2252
* @param project The MavenProject to record
2353
* @throws Exception if recording fails
2454
*/
2555
fun recordBuildState(project: MavenProject) {
56+
val selector = "${project.groupId}:${project.artifactId}"
2657
val startTime = System.currentTimeMillis()
2758
log.debug(" Recording build state for ${project.groupId}:${project.artifactId}...")
2859
val basedir = project.basedir
2960

3061
// Capture compile source roots
3162
val compileSourceRootsAbsolute = project.compileSourceRoots.toSet()
32-
val compileSourceRoots = PathUtils.toRelativePaths(compileSourceRootsAbsolute, basedir, log)
63+
val compileSourceRoots = toRelativePathsCached(compileSourceRootsAbsolute, basedir)
3364

3465
// Capture test compile source roots
3566
val testCompileSourceRootsAbsolute = project.testCompileSourceRoots.toSet()
36-
val testCompileSourceRoots = PathUtils.toRelativePaths(testCompileSourceRootsAbsolute, basedir, log)
67+
val testCompileSourceRoots = toRelativePathsCached(testCompileSourceRootsAbsolute, basedir)
3768

3869
// Capture resources
3970
val resourcesAbsolute = project.resources.map { (it as Resource).directory }.filter { it != null }.toSet()
40-
val resources = PathUtils.toRelativePaths(resourcesAbsolute, basedir, log)
71+
val resources = toRelativePathsCached(resourcesAbsolute, basedir)
4172

4273
// Capture test resources
4374
val testResourcesAbsolute = project.testResources.map { (it as Resource).directory }.filter { it != null }.toSet()
44-
val testResources = PathUtils.toRelativePaths(testResourcesAbsolute, basedir, log)
75+
val testResources = toRelativePathsCached(testResourcesAbsolute, basedir)
4576

4677
// Capture output directories
4778
val outputDirectory = project.build.outputDirectory?.let {
48-
PathUtils.toRelativePath(it, basedir, log)
79+
toRelativePathCached(it, basedir)
4980
}
5081
val testOutputDirectory = project.build.testOutputDirectory?.let {
51-
PathUtils.toRelativePath(it, basedir, log)
82+
toRelativePathCached(it, basedir)
5283
}
5384

5485
// Capture classpaths
@@ -64,7 +95,7 @@ object BuildStateRecorder {
6495
val defaultPomFile = File(basedir, "pom.xml")
6596
val currentPomFile = project.file
6697
val pomFile = if (currentPomFile != null && currentPomFile.canonicalPath != defaultPomFile.canonicalPath) {
67-
val relativePath = PathUtils.toRelativePath(currentPomFile.absolutePath, basedir, log)
98+
val relativePath = toRelativePathCached(currentPomFile.absolutePath, basedir)
6899
log.info("Captured non-default pomFile: $relativePath (modified by a plugin like flatten-maven-plugin)")
69100
relativePath
70101
} else {
@@ -87,10 +118,19 @@ object BuildStateRecorder {
87118
pomFile = pomFile
88119
)
89120

90-
// Write to file
121+
// Skip write if state is identical to what was last written
91122
val outputFile = File(project.build.directory, BUILD_STATE_FILE)
123+
val lastState = lastWrittenState[selector]
124+
if (lastState != null && lastState == buildState) {
125+
val duration = System.currentTimeMillis() - startTime
126+
log.debug(" Skipping write for $selector — state unchanged (took ${duration}ms)")
127+
return
128+
}
129+
130+
// Write to file
92131
outputFile.parentFile?.mkdirs()
93132
outputFile.writeText(gson.toJson(buildState))
133+
lastWrittenState[selector] = buildState
94134

95135
val duration = System.currentTimeMillis() - startTime
96136
log.debug(" Recorded to ${outputFile.absolutePath} (took ${duration}ms)")
@@ -107,14 +147,14 @@ object BuildStateRecorder {
107147
log.warn("Failed to capture $classpathType classpath: ${e.message}")
108148
emptySet<String>()
109149
}
110-
return PathUtils.toRelativePaths(absolutePaths, basedir, log)
150+
return toRelativePathsCached(absolutePaths, basedir)
111151
}
112152

113153
private fun captureMainArtifact(project: MavenProject, basedir: File): ArtifactInfo? {
114154
val artifactFile = project.artifact?.file
115155
if (artifactFile != null && artifactFile.exists()) {
116156
return ArtifactInfo(
117-
file = PathUtils.toRelativePath(artifactFile.absolutePath, basedir, log),
157+
file = toRelativePathCached(artifactFile.absolutePath, basedir),
118158
type = project.artifact.type,
119159
classifier = project.artifact.classifier,
120160
groupId = project.artifact.groupId,
@@ -144,7 +184,7 @@ object BuildStateRecorder {
144184
null
145185
}
146186
else -> ArtifactInfo(
147-
file = PathUtils.toRelativePath(artifact.file.absolutePath, basedir, log),
187+
file = toRelativePathCached(artifact.file.absolutePath, basedir),
148188
type = artifact.type,
149189
classifier = artifact.classifier,
150190
groupId = artifact.groupId,

0 commit comments

Comments
 (0)