gnodet-bot commented on code in PR #328:
URL:
https://github.com/apache/maven-clean-plugin/pull/328#discussion_r4083188003
##########
src/main/java/org/apache/maven/plugins/clean/BackgroundCleaner.java:
##########
@@ -111,44 +161,80 @@ final class BackgroundCleaner extends Cleaner implements
Listener, Runnable {
private boolean disabled;
/**
- * Creates a new cleaner to be executed in a background thread.
+ * Creates a new background cleaner service.
+ * Use {@link #getOrCreate} to obtain a session-scoped instance.
*
- * @param session the Maven session to be used
- * @param matcherFactory the service to use for creating include and
exclude filters.
- * @param logger the logger to use
- * @param verbose whether to perform verbose logging
- * @param fastDir the explicit configured directory or to be
deleted in fast mode
- * @param fastMode the fast deletion mode
- * @param followSymlinks whether to follow symlinks
- * @param force whether to force the deletion of read-only files
- * @param failOnError whether to abort with an exception in case a
selected file/directory could not be deleted
- * @param retryOnError whether to undertake additional delete attempts
in case the first attempt failed
+ * @param session the Maven session to be used
+ * @param logger the logger to use
+ * @param fastDir the directory where to temporarily move the files to
delete
+ * @param fastMode the fast deletion mode
*/
- @SuppressWarnings("checkstyle:ParameterNumber")
- BackgroundCleaner(
- @Nonnull Session session,
- @Nonnull PathMatcherFactory matcherFactory,
- @Nonnull Log logger,
- boolean verbose,
- @Nonnull Path fastDir,
- @Nonnull FastMode fastMode,
- boolean followSymlinks,
- boolean force,
- boolean failOnError,
- boolean retryOnError) {
- super(matcherFactory, logger, verbose, followSymlinks, force,
failOnError, retryOnError);
+ private BackgroundCleaner(
+ @Nonnull Session session, @Nonnull Log logger, @Nonnull Path
fastDir, @Nonnull FastMode fastMode) {
this.session = session;
+ this.logger = logger;
this.fastDir = fastDir;
this.fastMode = fastMode;
filesToDeleteAtEnd = (fastMode != FastMode.BACKGROUND) ? new
ArrayList<>() : null;
directoriesToDeleteIfEmpty = new LinkedHashSet<>(); // Will need to
delete in order.
executor = Executors.newSingleThreadExecutor((task) -> new
Thread(task, "mvn-background-cleaner"));
+ session.registerListener(this);
+ scanForLeftovers();
+ }
+
+ /**
+ * Returns the session-scoped {@code BackgroundCleaner}, creating it on
first access.
+ * The instance is stored in {@link SessionData} so that all subprojects
in a reactor
+ * share the same background thread and session listener.
+ *
+ * @param session the Maven session to be used
+ * @param logger the logger to use
+ * @param fastDir the directory where to temporarily move the files to
delete
+ * @param fastMode the fast deletion mode
+ * @return the shared background cleaner instance for the session
+ */
+ static BackgroundCleaner getOrCreate(
+ @Nonnull Session session, @Nonnull Log logger, @Nonnull Path
fastDir, @Nonnull FastMode fastMode) {
+ BackgroundCleaner bc =
+ session.getData().computeIfAbsent(KEY, () -> new
BackgroundCleaner(session, logger, fastDir, fastMode));
+ if (!bc.fastDir.equals(fastDir) || bc.fastMode != fastMode) {
+ logger.debug("BackgroundCleaner already initialized with fastDir="
+ bc.fastDir
+ + ", fastMode=" + bc.fastMode + "; ignoring fastDir=" +
fastDir
+ + ", fastMode=" + fastMode + " from this subproject.");
+ }
+ return bc;
+ }
+
+ /**
+ * Scans the fast directory for leftover directories from previous
(possibly killed) builds
+ * and queues them for background deletion. This restores the cleanup
behavior that was
+ * present in the singleton pattern of version 3.5.0 but was lost when
switching to
+ * per-subproject instances.
+ *
+ * <p><b>Limitation:</b> leftovers are always deleted with {@code
force=false}.
+ * Because the previous build's configuration is not persisted, we cannot
know
+ * whether it used {@code force=true}. As a consequence, read-only files
that
+ * survived a killed build will not be force-deleted here; they will
remain until
+ * the user runs a new clean with {@code force=true}.</p>
+ */
+ private void scanForLeftovers() {
+ if (Files.isDirectory(fastDir)) {
+ try (DirectoryStream<Path> stream =
Files.newDirectoryStream(fastDir)) {
+ for (Path child : stream) {
+ if (Files.isDirectory(child)) {
+ logger.debug("Cleaning leftover directory from
previous build: " + child);
+ executor.submit(() -> deleteInBackground(child, false,
true));
Review Comment:
๐งช **No unit tests for the new batch-retry and session-scoping logic**
This commit adds four non-trivial methods:
- `deleteInBackground` โ two-pass walk + sleep + retry with per-path `force`
handling
- `tryDeleteOnce` โ `AccessDeniedException` + `setWritable` + retry-once
semantics
- `getOrCreate` โ `computeIfAbsent` + mismatch detection
- `scanForLeftovers` โ `DirectoryStream` enumeration + executor submission
All of these have edge cases (empty directory, already-deleted file,
`force=true` on read-only file, parallel `getOrCreate` calls from two
subprojects, leftover from previous build) that are hard to verify from code
inspection alone. None are covered by a unit test.
The existing integration tests exercise the end-to-end `clean:clean` flow,
but they do not directly exercise the background retry logic or the
session-scoped sharing. A `BackgroundCleanerTest` covering at least
`tryDeleteOnce` (mocking a read-only file on Windows-style semantics) and
`getOrCreate` (verifying the same instance is returned on second call) would
significantly reduce the maintenance risk of this new complexity.
##########
src/main/java/org/apache/maven/plugins/clean/BackgroundCleaner.java:
##########
@@ -255,18 +339,111 @@ boolean fastDelete(Path baseDir) throws IOException {
}
/**
- * Deletes the given directory without logging messages and without
throwing {@link IOException}.
- * The exceptions are stored for reporting after the end of the session.
+ * Deletes the given directory in a background thread using batch retry.
+ * Unlike the foreground {@link Cleaner}, this method does not call {@code
System.gc()}
+ * or sleep per file, avoiding the stop-the-world JVM pauses that caused
the performance
+ * regression described in MCLEAN-102.
+ *
+ * <p>The deletion proceeds in two passes:</p>
+ * <ol>
+ * <li><b>Walk:</b> traverse the file tree and attempt to delete each
file/directory once.
+ * Failures are silently collected without retrying.</li>
+ * <li><b>Batch retry:</b> if {@code retryOnError} is enabled and there
were failures,
+ * sleep once ({@value #BATCH_RETRY_DELAY_MS}ms) to let external
processes release
+ * file locks, then retry all failures together.</li>
+ * </ol>
+ *
+ * <p>Any files that still cannot be deleted after the batch retry will be
cleaned up
+ * by the {@linkplain #scanForLeftovers() leftover scan} on the next
build.</p>
*
* <h4>Thread safety</h4>
- * Contrarily to most other methods in {@code BackgroundCleaner}, this
method is
- * thread-safe because it uses a copy of this cleaner for walking in the
file tree.
+ * This method is designed to run in the background executor thread. It
does not share
+ * any mutable state with the main thread except through {@link
#errorOccurred(IOException)},
+ * which is synchronized.
+ *
+ * @param dir the directory to delete
+ * @param force whether to force the deletion of read-only files
+ * @param retryOnError whether to undertake a batch retry of failed
deletions
*/
- private void deleteSilently(final Path dir) {
+ private void deleteInBackground(Path dir, boolean force, boolean
retryOnError) {
+ logger.debug("Deleting " + dir + " in background.");
+ List<Path> failures = new ArrayList<>();
try {
- Files.walkFileTree(dir, Set.of(), Integer.MAX_VALUE, new
Cleaner(this));
+ Files.walkFileTree(dir, Set.of(), Integer.MAX_VALUE, new
SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult visitFile(Path file,
BasicFileAttributes attrs) {
+ if (!tryDeleteOnce(file, force)) {
+ failures.add(file);
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path d, IOException
exc) {
+ if (!tryDeleteOnce(d, force)) {
+ failures.add(d);
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFileFailed(Path file, IOException
exc) {
+ failures.add(file);
+ return FileVisitResult.CONTINUE;
+ }
+ });
} catch (IOException e) {
errorOccurred(e);
+ return;
+ }
+ if (!failures.isEmpty() && retryOnError) {
+ try {
+ Thread.sleep(BATCH_RETRY_DELAY_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ int remaining = 0;
+ for (Path path : failures) {
+ if (!tryDeleteOnce(path, force)) {
+ remaining++;
+ }
+ }
+ if (remaining > 0) {
+ errorOccurred(new IOException("Failed to delete " + remaining
+ " file(s) during background clean;"
+ + " will retry on next build"));
+ }
+ } else if (!failures.isEmpty()) {
+ errorOccurred(new IOException("Failed to delete " +
failures.size() + " file(s) during background clean"));
Review Comment:
๐ฌ **Minor: error message says `file(s)` but the count includes directories**
The `failures` list accumulates entries from both `visitFile` (files) and
`postVisitDirectory` (directories). The error message says `"file(s)"` in both
the retry path (line 413) and the no-retry path (line 417), which is slightly
misleading when read in a build log.
Consider `"path(s)"` or `"entry/entries"` โ something that is accurate
whether the failures are files, directories, or both.
```suggestion
errorOccurred(new IOException("Failed to delete " +
remaining + " path(s) during background clean;"
+ " will retry on next build"));
}
} else if (!failures.isEmpty()) {
errorOccurred(new IOException("Failed to delete " +
failures.size() + " path(s) during background clean"));
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]