slawekjaranowski commented on code in PR #347:
URL:
https://github.com/apache/maven-clean-plugin/pull/347#discussion_r4111154259
##########
src/main/java/org/apache/maven/plugins/clean/BackgroundCleaner.java:
##########
@@ -255,18 +381,156 @@ 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<>() {
+ /**
+ * Current depth relative to the root directory, used by
{@link Cleaner#setWritable}
+ * to walk up to the parent when the file itself is already
writable.
+ */
+ int depth;
+
+ @Override
+ public FileVisitResult preVisitDirectory(Path d,
BasicFileAttributes attrs) {
+ if (ON_WINDOWS && attrs.isOther()) {
+ // MCLEAN-93: NTFS junctions have isDirectory() and
isOther() attributes set.
+ // Delete the junction itself and skip its contents to
avoid deleting the
+ // contents of the junction target, which may be
outside the project.
+ if (!tryDeleteOnce(d, force, depth)) {
+ failures.add(d);
+ }
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ depth++;
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file,
BasicFileAttributes attrs) {
+ if (!tryDeleteOnce(file, force, depth)) {
+ failures.add(file);
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path d, IOException
exc) {
+ depth--;
+ if (!tryDeleteOnce(d, force, depth)) {
+ 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();
+ // Report collected failures before returning.
+ errorOccurred(buildFailureException(failures));
+ return;
+ }
+ List<Path> remaining = new ArrayList<>();
+ for (Path path : failures) {
+ if (!tryDeleteOnce(path, force, 0)) {
+ remaining.add(path);
+ }
+ }
+ if (!remaining.isEmpty()) {
+ errorOccurred(buildFailureException(remaining));
+ }
+ } else if (!failures.isEmpty()) {
+ errorOccurred(buildFailureException(failures));
+ }
+ }
+
+ /**
+ * Builds an {@link IOException} that reports the failing paths (capped at
10) and total count.
+ *
+ * @param failures the list of paths that could not be deleted
+ * @return an exception describing the failures
+ */
+ private static IOException buildFailureException(List<Path> failures) {
+ StringBuilder sb = new StringBuilder("Failed to delete ")
+ .append(failures.size())
+ .append(" path(s) during background clean");
+ int limit = Math.min(failures.size(), 10);
+ for (int i = 0; i < limit; i++) {
+ sb.append("\n ").append(failures.get(i));
+ }
+ if (failures.size() > limit) {
+ sb.append("\n ... and ").append(failures.size() - limit).append("
more");
+ }
+ return new IOException(sb.toString());
+ }
+
+ /**
+ * Tries to delete a single file or directory once, without retry delays
or {@code System.gc()}.
+ * If {@code force} is enabled and deletion fails with {@link
AccessDeniedException},
+ * the file (or its parent directory) is made writable and deletion is
retried immediately (once).
+ *
+ * @param file the file or directory to delete
+ * @param force whether to make read-only files writable before retrying
+ * @param currentDepth the depth of the file relative to the staged root,
used by
+ * {@link Cleaner#setWritable} to walk up to the
parent directory
+ * @return {@code true} if the file was deleted or did not exist
+ */
+ private static boolean tryDeleteOnce(Path file, boolean force, int
currentDepth) {
+ try {
+ Files.deleteIfExists(file);
+ return true;
+ } catch (AccessDeniedException e) {
+ if (force) {
+ try {
+ Cleaner.setWritable(file, currentDepth);
Review Comment:
Reopening this point (I resolved the earlier thread too early — I had only
checked the variant where the file itself stays writable).
The depth argument is fixed, but `force` still cannot handle a file **and**
its parent directory both being read-only — which is exactly what
`src/it/read-only/setup.groovy` sets up.
`setWritable` returns as soon as it has actually granted write permission to
something. When the file itself is read-only, this single call makes the *file*
writable and returns, never reaching the read-only parent, so the following
`deleteIfExists` throws `AccessDeniedException` again. The foreground
`Cleaner.tryDelete` avoids that by looping:
```java
while (madeWritable.add(setWritable(file, currentDepth))) {
...
}
```
each iteration going one level further up. `tryDeleteOnce` calls it once.
I verified this end to end against 5c09dbe: built and installed the plugin,
then ran the same project twice with Maven 4.0.0-rc-6, `force=true`,
`retryOnError=false`, `target/read-only-dir` at `dr-xr-xr-x` containing a file
at `r--r--r--`:
| | result |
|---|---|
| `fast=false` | `target` deleted, BUILD SUCCESS, no warnings |
| `fast=true` | `target` moved into `.fastdir` and left there |
```
[WARNING] Errors during background file deletion.
java.io.IOException: Failed to delete 3 path(s) during background clean
…/.fastdir/probe-…/read-only-dir/read-only.properties
…/.fastdir/probe-…/read-only-dir
…/.fastdir/probe-…
[INFO] BUILD SUCCESS
```
The path list is the new reporting working as intended — thanks for that.
Worth noting separately that `failOnError=true` (the default) does not apply to
the background path, so the build passes while files are left behind, where the
foreground would have failed.
Both my earlier unit-test suggestion and the test added in e90cf6e make only
the *directory* read-only and leave the file writable. In that shape a single
`setWritable` call is enough, which is why they pass.
Two suggestions:
1. Make `tryDeleteOnce` loop the way `Cleaner.tryDelete` does, with the same
`madeWritable` set guarding against a never-ending loop.
2. An IT for this already exists and only needs a fast-mode twin.
`src/it/read-only` sets both the file and (on Unix) the directory non-writable
with `force=true`, but it only ever exercises the foreground path, because fast
mode is enabled per-IT through `<fast>true</fast>` in the IT's own POM. A copy
— `src/it/read-only-fast`, same `setup.groovy`, plus `<fast>true</fast>` and a
`<fastDir>`, with `verify.groovy` also asserting the staging directory is gone
— would give end-to-end coverage of `force` in the background path. I tried
exactly that locally and it fails on the current head, so it would have caught
this.
--
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]