slawekjaranowski commented on code in PR #345:
URL:
https://github.com/apache/maven-clean-plugin/pull/345#discussion_r4107264889
##########
src/main/java/org/apache/maven/plugins/clean/Cleaner.java:
##########
@@ -294,7 +318,42 @@ public final void delete(@Nonnull Path basedir) throws
IOException {
logger.debug(fastDeleteError(e), e);
}
}
+ if (retryOnError) {
+ retryQueue = new ArrayList<>();
+ }
Files.walkFileTree(basedir, options, Integer.MAX_VALUE, this);
+ /*
+ * Batch retry: if any deletions failed during the walk, sleep once
and retry all of them.
+ * This is the same strategy used by
BackgroundCleaner.deleteInBackground() (see MCLEAN-102):
+ * a single sleep lets external processes (virus scanners, search
indexers) release file locks,
+ * without the stop-the-world System.gc() calls and per-file sleep
loops that were used before.
+ */
+ if (retryQueue != null && !retryQueue.isEmpty()) {
+ try {
+ Thread.sleep(BATCH_RETRY_DELAY_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ // Interrupted during the batch-retry sleep. Restore the flag
and stop retrying;
+ // any remaining paths in the queue are abandoned. The
caller's next blocking
+ // call (e.g. BlockingQueue.take() in BackgroundCleaner) will
see the flag.
+ retryQueue = null;
+ return;
+ }
+ for (Path path : retryQueue) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException e) {
+ if (logger.isWarnEnabled()) {
+ logger.warn("Failed to delete " + path + " after batch
retry", e);
Review Comment:
Two things here.
- This is where the ancestor warnings pile up; see the comment on the
tentative `return true`.
- The reported exception is the fresh one from this `Files.deleteIfExists`,
so the original failure from the walk phase — including its
`AccessDeniedException` message — is not attached. Queueing the walk-phase
exception together with the path and chaining the two would make the warning
diagnosable.
Also, with `failOnError=true` this `throw e` leaves the loop, so the
remaining queued paths are never attempted and never reported. Master aborted
mid-walk instead, so this is a change either way, but the silently skipped tail
is worth a thought.
##########
src/main/java/org/apache/maven/plugins/clean/Cleaner.java:
##########
@@ -355,7 +414,7 @@ public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) th
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
if (fileMatcher.matches(file) && tryDelete(file)) {
- if (listDeletedFiles) {
+ if (listDeletedFiles && !pendingRetry) {
Review Comment:
Suppressing the log here is right, but nothing logs the deletion when the
batch retry later succeeds, so `verbose=true` loses the message entirely.
Measured against master:
| | with `verbose=true` |
|---|---|
| master | `Deleting "…/target".`, `Deleted file …/late.txt`, `Deleted
directory …/target` |
| this PR | `Deleting "…/target".` only |
The retry loop would have to call `logDelete` on success. That needs the
`BasicFileAttributes` — or at least the directory-vs-file bit — to travel with
the path, which is another reason to queue a small record rather than a bare
`Path`.
##########
src/main/java/org/apache/maven/plugins/clean/Cleaner.java:
##########
@@ -476,68 +535,77 @@ private static Path setWritable(Path file, int
currentDepth) throws IOException
}
/**
- * Deletes the specified file or directory.
+ * Attempts to delete the specified file or directory (one attempt only
during the walk phase).
* If the path denotes a symlink, only the link is removed. Its target is
left untouched.
* This method returns {@code true} if the file has been deleted, or
{@code false} if the
* file does not exist or if an {@link IOException} occurred but {@link
#failOnError} is
* {@code false}.
*
+ * <h4>Retry strategy</h4>
+ * If deletion fails with an {@link AccessDeniedException} and {@code
force} is enabled,
+ * this method first tries to make the file writable and retries
<em>immediately</em>
+ * (this is a permissions fix, not a timing retry).
+ * For all other transient failures (e.g. Windows file locks held by virus
scanners),
+ * the path is added to {@link #retryQueue} and the batch retry in {@link
#delete(Path)}
+ * will retry it after a single {@value #BATCH_RETRY_DELAY_MS}ms sleep —
without calling
+ * {@code System.gc()} or sleeping per file.
+ *
* <h4>Auxiliary information as side-effect</h4>
* This method sets the {@link #reallyDeletedLastFile} flag to whether
this method really deleted the file.
* If that flag is {@code false} after this method returned {@code true},
then the file has been deleted by
- * some concurrent process before this method tried to deleted the file.
+ * some concurrent process before this method tried to delete the file.
* That flag is used for logging purpose only.
*
* @param file the file/directory to delete, must not be {@code null}
- * @return whether the file has been deleted or did not existed anymore by
the time this method is invoked
+ * @return whether the file has been deleted or did not exist anymore by
the time this method is invoked
* @throws IOException if a file/directory could not be deleted and {@code
failOnError} is {@code true}
*/
- @SuppressWarnings("SleepWhileInLoop")
private boolean tryDelete(final Path file) throws IOException {
+ pendingRetry = false;
try {
reallyDeletedLastFile = Files.deleteIfExists(file);
return true;
} catch (IOException failure) {
- boolean tryWritable = force && failure instanceof
AccessDeniedException;
- if (tryWritable || retryOnError) {
- final Set<Path> madeWritable; // Safety against never-ending
loops.
- if (force) {
- madeWritable = new HashSet<>();
- madeWritable.add(null); // For having `add(null)` to
return `false`.
- } else {
- madeWritable = null;
- }
- final var alreadyReported = new HashMap<Class<?>,
Set<String>>(); // For avoiding repetition.
- isNewError(alreadyReported, failure);
- int delayIndex = 0;
- while (delayIndex < RETRY_DELAYS.length) {
- if (tryWritable) {
- tryWritable = madeWritable.add(setWritable(file,
currentDepth));
- // `true` if we successfully changed permission, in
which case we will skip the delay.
- }
- if (!tryWritable) {
- if (ON_WINDOWS) {
- // Try to release any locks held by non-closed
files.
- System.gc();
- }
- try {
- Thread.sleep(RETRY_DELAYS[delayIndex++]);
- } catch (InterruptedException e) {
- failure.addSuppressed(e);
- throw failure;
- }
- }
+ /*
+ * If force=true and the failure is an AccessDeniedException, try
to make the file
+ * writable and retry immediately. This is a permissions fix (not
a timing retry)
+ * and is safe to do inline without a sleep.
+ */
+ if (force && failure instanceof AccessDeniedException) {
+ final Set<Path> madeWritable = new HashSet<>();
+ madeWritable.add(null); // sentinel so add(null) returns false
+ while (madeWritable.add(setWritable(file, currentDepth))) {
try {
reallyDeletedLastFile = Files.deleteIfExists(file);
return true;
} catch (IOException again) {
- tryWritable = force && failure instanceof
AccessDeniedException;
- if (isNewError(alreadyReported, again)) {
+ if (!(again instanceof AccessDeniedException)) {
failure.addSuppressed(again);
+ failure = again;
+ break;
}
+ failure.addSuppressed(again);
+ failure = again;
}
}
}
+ /*
+ * The deletion failed. If retryOnError=true, add the path to the
batch retry queue
+ * rather than sleeping here. The batch retry in delete(Path) will
sleep once after
+ * the full tree walk and retry all failures together (see
MCLEAN-102 / #281).
+ * No System.gc() is called — that is an unreliable,
stop-the-world hint.
+ *
+ * Return true (tentatively successful) so that the caller does
not mark the parent
+ * directory as non-empty: if the batch retry succeeds for this
path, the parent
+ * directory will also be retried and deleted. If the batch retry
also fails, the
+ * directory deletion will fail with DirectoryNotEmptyException
and be reported.
+ */
+ if (retryQueue != null) {
+ retryQueue.add(file);
+ reallyDeletedLastFile = false;
+ pendingRetry = true;
+ return true; // tentatively: we expect the retry to succeed
Review Comment:
This is the root of the warning cascade in the summary. Returning `true`
makes `visitFile` and `postVisitDirectory` skip their `else` branch, so
`nonEmptyDirectoryLevels` is never set for this level; every ancestor directory
is then attempted, fails with `DirectoryNotEmptyException`, and is queued as
well. For `target/a/b/c/locked.txt` that is 5 queued paths and 5 warnings where
master logged 1, and it scales with depth.
(`failureCount` inflates the same way, but that field is never read anywhere
— pre-existing dead code, not something this PR has to deal with.)
The tentative `true` is what lets the parents succeed after a successful
retry, so I would keep it and make the *reporting* root-cause aware instead: in
the batch retry, skip the warning for a `DirectoryNotEmptyException` on a
directory that still has a failing descendant in the same queue, and report
only the leaf cause. That gives one warning per real problem.
##########
src/main/java/org/apache/maven/plugins/clean/Cleaner.java:
##########
@@ -476,68 +535,77 @@ private static Path setWritable(Path file, int
currentDepth) throws IOException
}
/**
- * Deletes the specified file or directory.
+ * Attempts to delete the specified file or directory (one attempt only
during the walk phase).
* If the path denotes a symlink, only the link is removed. Its target is
left untouched.
* This method returns {@code true} if the file has been deleted, or
{@code false} if the
* file does not exist or if an {@link IOException} occurred but {@link
#failOnError} is
* {@code false}.
*
+ * <h4>Retry strategy</h4>
+ * If deletion fails with an {@link AccessDeniedException} and {@code
force} is enabled,
+ * this method first tries to make the file writable and retries
<em>immediately</em>
+ * (this is a permissions fix, not a timing retry).
+ * For all other transient failures (e.g. Windows file locks held by virus
scanners),
+ * the path is added to {@link #retryQueue} and the batch retry in {@link
#delete(Path)}
+ * will retry it after a single {@value #BATCH_RETRY_DELAY_MS}ms sleep —
without calling
+ * {@code System.gc()} or sleeping per file.
+ *
* <h4>Auxiliary information as side-effect</h4>
* This method sets the {@link #reallyDeletedLastFile} flag to whether
this method really deleted the file.
* If that flag is {@code false} after this method returned {@code true},
then the file has been deleted by
- * some concurrent process before this method tried to deleted the file.
+ * some concurrent process before this method tried to delete the file.
* That flag is used for logging purpose only.
*
* @param file the file/directory to delete, must not be {@code null}
- * @return whether the file has been deleted or did not existed anymore by
the time this method is invoked
+ * @return whether the file has been deleted or did not exist anymore by
the time this method is invoked
* @throws IOException if a file/directory could not be deleted and {@code
failOnError} is {@code true}
*/
- @SuppressWarnings("SleepWhileInLoop")
private boolean tryDelete(final Path file) throws IOException {
+ pendingRetry = false;
try {
reallyDeletedLastFile = Files.deleteIfExists(file);
return true;
} catch (IOException failure) {
- boolean tryWritable = force && failure instanceof
AccessDeniedException;
- if (tryWritable || retryOnError) {
- final Set<Path> madeWritable; // Safety against never-ending
loops.
- if (force) {
- madeWritable = new HashSet<>();
- madeWritable.add(null); // For having `add(null)` to
return `false`.
- } else {
- madeWritable = null;
- }
- final var alreadyReported = new HashMap<Class<?>,
Set<String>>(); // For avoiding repetition.
- isNewError(alreadyReported, failure);
- int delayIndex = 0;
- while (delayIndex < RETRY_DELAYS.length) {
- if (tryWritable) {
- tryWritable = madeWritable.add(setWritable(file,
currentDepth));
- // `true` if we successfully changed permission, in
which case we will skip the delay.
- }
- if (!tryWritable) {
- if (ON_WINDOWS) {
- // Try to release any locks held by non-closed
files.
- System.gc();
- }
- try {
- Thread.sleep(RETRY_DELAYS[delayIndex++]);
- } catch (InterruptedException e) {
- failure.addSuppressed(e);
- throw failure;
- }
- }
+ /*
+ * If force=true and the failure is an AccessDeniedException, try
to make the file
+ * writable and retry immediately. This is a permissions fix (not
a timing retry)
+ * and is safe to do inline without a sleep.
+ */
+ if (force && failure instanceof AccessDeniedException) {
+ final Set<Path> madeWritable = new HashSet<>();
+ madeWritable.add(null); // sentinel so add(null) returns false
+ while (madeWritable.add(setWritable(file, currentDepth))) {
try {
reallyDeletedLastFile = Files.deleteIfExists(file);
return true;
} catch (IOException again) {
- tryWritable = force && failure instanceof
AccessDeniedException;
- if (isNewError(alreadyReported, again)) {
+ if (!(again instanceof AccessDeniedException)) {
failure.addSuppressed(again);
+ failure = again;
+ break;
}
+ failure.addSuppressed(again);
+ failure = again;
Review Comment:
I think the chaining is the wrong way round, despite the "exception
chaining" fix in fd1ed42. `failure.addSuppressed(again)` attaches `again` to
the object that the very next statement stops referencing, so after a second
iteration the original `AccessDeniedException` is unreachable from `failure`
and only the last exception is reported.
`again.addSuppressed(failure); failure = again;` would keep the history — or
keep the original in `failure` and only ever add to it.
Separately, both branches of the `if` above run the same two statements and
differ only by the `break`, so it could collapse to:
```java
failure.addSuppressed(again); // or the corrected order
failure = again;
if (!(again instanceof AccessDeniedException)) {
break;
}
```
I could not reproduce the loss on POSIX — once `setWritable` succeeds the
deletion succeeds, so the loop does not iterate twice — so this one is from
reading the code, not from a measurement.
##########
src/test/java/org/apache/maven/plugins/clean/CleanerTest.java:
##########
@@ -134,4 +136,85 @@ void
deleteDoesNotLogAnythingWhenNoPermissionAndWarnDisabled(@TempDir Path tempD
assertDoesNotThrow(() -> cleaner.delete(basedir));
verify(log, never()).warn(any(CharSequence.class),
any(Throwable.class));
}
+
+ /**
+ * Verifies the batch retry strategy: a file that fails during the tree
walk but becomes
+ * deletable before the batch retry completes is deleted without any error
being reported.
+ *
+ * <p>This test simulates the Windows scenario where a file is temporarily
locked by an
+ * external process (virus scanner, search indexer): the directory is made
non-writable during
+ * the walk, then a background thread restores write permission during the
+ * {@value Cleaner#BATCH_RETRY_DELAY_MS}ms batch-retry sleep, allowing the
retry to succeed.
+ * No {@code System.gc()} is called between attempts — that was the
anti-pattern removed by
+ * this fix (see issue #281).</p>
+ */
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void batchRetrySucceedsWhenFileBecomesAvailableDuringDelay(@TempDir Path
tempDir) throws Exception {
+ when(log.isWarnEnabled()).thenReturn(true);
+ final Path basedir =
createDirectory(tempDir.resolve("target")).toRealPath();
+ final Path file = createFile(basedir.resolve("file"));
+
+ // Make the directory non-writable so the first deletion attempt on
the file fails.
+ final Set<PosixFilePermission> noWrite =
PosixFilePermissions.fromString("r-xr-xr-x");
+ final Set<PosixFilePermission> writable =
PosixFilePermissions.fromString("rwxrwxr-x");
+ setPosixFilePermissions(basedir, noWrite);
+
+ // Schedule a background thread to restore write permission during the
batch-retry sleep.
+ // The batch sleep is BATCH_RETRY_DELAY_MS (250ms); we restore after
50ms to give plenty of margin.
+ final CountDownLatch walkStarted = new CountDownLatch(1);
+ Thread restorer = new Thread(() -> {
+ try {
+ walkStarted.await(5, TimeUnit.SECONDS);
+ Thread.sleep(50);
+ setPosixFilePermissions(basedir, writable);
+ } catch (Exception ignored) {
+ }
+ });
+ restorer.setDaemon(true);
+ restorer.start();
+
+ // Signal the restorer thread immediately before starting the delete.
+ walkStarted.countDown();
Review Comment:
`walkStarted` is counted down here, before `cleaner.delete()` is called
below, so the latch does not mark the start of the walk and the restorer's 50
ms timer starts before the walk begins. What actually keeps the test honest is
the 50 ms versus 250 ms margin.
More importantly, the assertions cannot detect the batch retry at all. I ran
this test with the `setPosixFilePermissions(basedir, noWrite)` line removed —
nothing fails, so the retry never runs — and `verify(log, never()).warn(...)`
plus both `assertFalse(exists(...))` still pass. To pin the behaviour the test
needs a positive signal that the retry ran: assert that the walk-phase attempt
really failed, inject the sleep, or at minimum assert elapsed time is at least
`BATCH_RETRY_DELAY_MS`.
##########
src/main/java/org/apache/maven/plugins/clean/Cleaner.java:
##########
@@ -145,17 +152,34 @@ class Cleaner implements FileVisitor<Path> {
private final boolean retryOnError;
/**
- * The delays (in milliseconds) if {@link #retryOnError} is {@code true}.
- * The length of this array is the maximal number of new attempts.
+ * Delay in milliseconds for the single batch retry after a full tree walk.
+ * {@link BackgroundCleaner} reuses this constant via {@link
#delete(Path)} (called from
+ * {@code deleteSilently}): both the foreground and background clean paths
apply a single sleep
+ * before retrying all failures, without any per-file {@code System.gc()}
call.
+ *
+ * @see #delete(Path)
*/
- private static final int[] RETRY_DELAYS = new int[] {50, 250, 750};
+ static final int BATCH_RETRY_DELAY_MS = 250;
Review Comment:
The Javadoc says this is package-visible so that
`BackgroundCleaner.deleteSilently`'s Javadoc can reference it through
`{@value}`. Widening visibility only to satisfy a doc link is thin — `private`
plus a plain "250 ms" in the other class's prose would read the same. And if
#347 lands, `BackgroundCleaner` gets its own constant of the same name, so the
two need reconciling regardless.
##########
src/main/java/org/apache/maven/plugins/clean/Cleaner.java:
##########
@@ -294,7 +318,42 @@ public final void delete(@Nonnull Path basedir) throws
IOException {
logger.debug(fastDeleteError(e), e);
}
}
+ if (retryOnError) {
+ retryQueue = new ArrayList<>();
+ }
Files.walkFileTree(basedir, options, Integer.MAX_VALUE, this);
+ /*
+ * Batch retry: if any deletions failed during the walk, sleep once
and retry all of them.
+ * This is the same strategy used by
BackgroundCleaner.deleteInBackground() (see MCLEAN-102):
+ * a single sleep lets external processes (virus scanners, search
indexers) release file locks,
+ * without the stop-the-world System.gc() calls and per-file sleep
loops that were used before.
+ */
+ if (retryQueue != null && !retryQueue.isEmpty()) {
+ try {
+ Thread.sleep(BATCH_RETRY_DELAY_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ // Interrupted during the batch-retry sleep. Restore the flag
and stop retrying;
+ // any remaining paths in the queue are abandoned. The
caller's next blocking
+ // call (e.g. BlockingQueue.take() in BackgroundCleaner) will
see the flag.
Review Comment:
There is no `BlockingQueue` in `BackgroundCleaner`; it uses an
`ExecutorService`, and `grep -rn BlockingQueue src/` matches only this comment.
Worth rewording so it does not send the next reader looking for code that is
not there.
On the behaviour itself: this path abandons the queue with no warning and no
report, so an interrupt during the batch sleep leaves files behind while
`delete()` returns normally to `CleanMojo`, which treats that as success. A
`logger.warn` before returning would at least leave a trace.
##########
src/main/java/org/apache/maven/plugins/clean/BackgroundCleaner.java:
##########
@@ -255,16 +255,22 @@ boolean fastDelete(Path baseDir) throws IOException {
}
/**
- * Deletes the given directory without logging messages and without
throwing {@link IOException}.
+ * Deletes the given directory without throwing {@link IOException}.
* The exceptions are stored for reporting after the end of the session.
*
+ * <p>Uses a copy of this cleaner ({@link Cleaner#Cleaner(Cleaner)}) which
shares
+ * the {@code retryOnError} configuration. The batch-retry strategy in
+ * {@link Cleaner#delete(Path)} ({@value Cleaner#BATCH_RETRY_DELAY_MS}ms
sleep
+ * after the full tree walk) applies automatically — no per-file {@code
System.gc()}
+ * is called.</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.
*/
private void deleteSilently(final Path dir) {
try {
- Files.walkFileTree(dir, Set.of(), Integer.MAX_VALUE, new
Cleaner(this));
+ new Cleaner(this).delete(dir);
Review Comment:
Two concerns.
- `Cleaner.delete(Path)` logs `logger.info("Deleting \"" + basedir + "\".")`
unconditionally, so every staged directory now produces an info line from the
background thread at session end — paths like `…/target/.clean/module-12345`
that mean nothing to the user. This method's contract was "without logging
messages", and the PR edits that sentence out of the Javadoc rather than
addressing it.
- #347 removes `deleteSilently` and the `Cleaner(Cleaner)` copy-constructor
usage altogether and adds its own `BATCH_RETRY_DELAY_MS` to
`BackgroundCleaner`; `git merge-tree` confirms the two branches conflict in
both files. If #347 lands first this hunk disappears, so it may be cleanest to
drop the `BackgroundCleaner` change here and rebase the `Cleaner.java` part on
top of #347.
--
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]