diff --git a/pom.xml b/pom.xml index cebba16..4e52070 100644 --- a/pom.xml +++ b/pom.xml @@ -115,7 +115,7 @@ under the License. org.apache.maven maven-core ${mavenVersion} - test + provided junit diff --git a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java index 0561ad7..4b5ba86 100644 --- a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java +++ b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java @@ -19,6 +19,7 @@ * under the License. */ +import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugins.annotations.Mojo; @@ -48,6 +49,12 @@ public class CleanMojo extends AbstractMojo { + public static final String FAST_MODE_BACKGROUND = "background"; + + public static final String FAST_MODE_AT_END = "at-end"; + + public static final String FAST_MODE_DEFER = "defer"; + /** * This is where build results go. */ @@ -161,6 +168,49 @@ public class CleanMojo @Parameter( property = "maven.clean.excludeDefaultDirectories", defaultValue = "false" ) private boolean excludeDefaultDirectories; + /** + * Enables fast clean if possible. If set to true, when the plugin is executed, a directory to + * be deleted will be atomically moved inside the maven.clean.fastDir directory and a thread will + * be launched to delete the needed files in the background. When the build is completed, maven will wait + * until all the files have been deleted. If any problem occurs during the atomic move of the directories, + * the plugin will default to the traditional deletion mechanism. + * + * @since 3.2 + */ + @Parameter( property = "maven.clean.fast", defaultValue = "false" ) + private boolean fast; + + /** + * When fast clean is specified, the fastDir property will be used as the location where directories + * to be deleted will be moved prior to background deletion. If not specified, the + * ${maven.multiModuleProjectDirectory}/target/.clean directory will be used. If the + * ${build.directory} has been modified, you'll have to adjust this property explicitly. + * In order for fast clean to work correctly, this directory and the various directories that will be deleted + * should usually reside on the same volume. The exact conditions are system dependant though, but if an atomic + * move is not supported, the standard deletion mechanism will be used. + * + * @since 3.2 + * @see #fast + */ + @Parameter( property = "maven.clean.fastDir" ) + private File fastDir; + + /** + * Mode to use when using fast clean. Values are: background to start deletion immediately and + * waiting for all files to be deleted when the session ends, at-end to indicate that the actual + * deletion should be performed synchronously when the session ends, or defer to specify that + * the actual file deletion should be started in the background when the session ends (this should only be used + * when maven is embedded in a long running process). + * + * @since 3.2 + * @see #fast + */ + @Parameter( property = "maven.clean.fastMode", defaultValue = FAST_MODE_BACKGROUND ) + private String fastMode; + + @Parameter( defaultValue = "${session}", readonly = true ) + private MavenSession session; + /** * Deletes file-sets in the following project build directory order: (source) directory, output directory, test * directory, report directory, and then the additional file-sets. @@ -177,7 +227,36 @@ public void execute() return; } - Cleaner cleaner = new Cleaner( getLog(), isVerbose() ); + String multiModuleProjectDirectory = session != null + ? session.getSystemProperties().getProperty( "maven.multiModuleProjectDirectory" ) : null; + File fastDir; + if ( fast && this.fastDir != null ) + { + fastDir = this.fastDir; + } + else if ( fast && multiModuleProjectDirectory != null ) + { + fastDir = new File( multiModuleProjectDirectory, "target/.clean" ); + } + else + { + fastDir = null; + if ( fast ) + { + getLog().warn( "Fast clean requires maven 3.3.1 or newer, " + + "or an explicit directory to be specified with the 'fastDir' configuration of " + + "this plugin, or the 'maven.clean.fastDir' user property to be set." ); + } + } + if ( fast && !FAST_MODE_BACKGROUND.equals( fastMode ) + && !FAST_MODE_AT_END.equals( fastMode ) + && !FAST_MODE_DEFER.equals( fastMode ) ) + { + throw new IllegalArgumentException( "Illegal value '" + fastMode + "' for fastMode. Allowed values are '" + + FAST_MODE_BACKGROUND + "', '" + FAST_MODE_AT_END + "' and '" + FAST_MODE_DEFER + "'." ); + } + + Cleaner cleaner = new Cleaner( session, getLog(), isVerbose(), fastDir, fastMode ); try { diff --git a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java index 33dbd49..4e6e9d2 100644 --- a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java +++ b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java @@ -21,10 +21,23 @@ import java.io.File; import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayDeque; +import java.util.Deque; +import org.apache.maven.execution.ExecutionListener; +import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.logging.Log; import org.apache.maven.shared.utils.Os; +import org.eclipse.aether.SessionData; + +import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_BACKGROUND; +import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_DEFER; /** * Cleans directories. @@ -36,6 +49,13 @@ class Cleaner private static final boolean ON_WINDOWS = Os.isFamily( Os.FAMILY_WINDOWS ); + private static final String LAST_DIRECTORY_TO_DELETE = Cleaner.class.getName() + ".lastDirectoryToDelete"; + + /** + * The maven session. This is typically non-null in a real run, but it can be during unit tests. + */ + private final MavenSession session; + private final Logger logDebug; private final Logger logInfo; @@ -44,13 +64,17 @@ class Cleaner private final Logger logWarn; + private final File fastDir; + + private final String fastMode; + /** * Creates a new cleaner. - * * @param log The logger to use, may be null to disable logging. * @param verbose Whether to perform verbose logging. + * @param fastMode The fast deletion mode */ - Cleaner( final Log log, boolean verbose ) + Cleaner( MavenSession session, final Log log, boolean verbose, File fastDir, String fastMode ) { logDebug = ( log == null || !log.isDebugEnabled() ) ? null : log::debug; @@ -59,6 +83,10 @@ class Cleaner logWarn = ( log == null || !log.isWarnEnabled() ) ? null : log::warn; logVerbose = verbose ? logInfo : logDebug; + + this.session = session; + this.fastDir = fastDir; + this.fastMode = fastMode; } /** @@ -97,9 +125,96 @@ public void delete( File basedir, Selector selector, boolean followSymlinks, boo File file = followSymlinks ? basedir : basedir.getCanonicalFile(); + if ( selector == null && !followSymlinks && fastDir != null && session != null ) + { + // If anything wrong happens, we'll just use the usual deletion mechanism + if ( fastDelete( file ) ) + { + return; + } + } + delete( file, "", selector, followSymlinks, failOnError, retryOnError ); } + private boolean fastDelete( File baseDirFile ) + { + Path baseDir = baseDirFile.toPath(); + Path fastDir = this.fastDir.toPath(); + // Handle the case where we use ${maven.multiModuleProjectDirectory}/target/.clean for example + if ( fastDir.toAbsolutePath().startsWith( baseDir.toAbsolutePath() ) ) + { + try + { + String prefix = baseDir.getFileName().toString() + "."; + Path tmpDir = Files.createTempDirectory( baseDir.getParent(), prefix ); + try + { + Files.move( baseDir, tmpDir, StandardCopyOption.REPLACE_EXISTING ); + if ( session != null ) + { + session.getRepositorySession().getData().set( LAST_DIRECTORY_TO_DELETE, baseDir.toFile() ); + } + baseDir = tmpDir; + } + catch ( IOException e ) + { + Files.delete( tmpDir ); + throw e; + } + } + catch ( IOException e ) + { + if ( logDebug != null ) + { + // TODO: this Logger interface cannot log exceptions and needs refactoring + logDebug.log( "Unable to fast delete directory: " + e ); + } + return false; + } + } + // Create fastDir and the needed parents if needed + try + { + if ( !Files.isDirectory( fastDir ) ) + { + Files.createDirectories( fastDir ); + } + } + catch ( IOException e ) + { + if ( logDebug != null ) + { + // TODO: this Logger interface cannot log exceptions and needs refactoring + logDebug.log( "Unable to fast delete directory as the path " + + fastDir + " does not point to a directory or cannot be created: " + e ); + } + return false; + } + + try + { + Path tmpDir = Files.createTempDirectory( fastDir, "" ); + Path dstDir = tmpDir.resolve( baseDir.getFileName() ); + // Note that by specifying the ATOMIC_MOVE, we expect an exception to be thrown + // if the path leads to a directory on another mountpoint. If this is the case + // or any other exception occurs, an exception will be thrown in which case + // the method will return false and the usual deletion will be performed. + Files.move( baseDir, dstDir, StandardCopyOption.ATOMIC_MOVE ); + BackgroundCleaner.delete( this, tmpDir.toFile(), fastMode ); + return true; + } + catch ( IOException e ) + { + if ( logDebug != null ) + { + // TODO: this Logger interface cannot log exceptions and needs refactoring + logDebug.log( "Unable to fast delete directory: " + e ); + } + return false; + } + } + /** * Deletes the specified file or directory. * @@ -268,4 +383,200 @@ private interface Logger } + private static class BackgroundCleaner extends Thread + { + + private static BackgroundCleaner instance; + + private final Deque filesToDelete = new ArrayDeque<>(); + + private final Cleaner cleaner; + + private final String fastMode; + + private static final int NEW = 0; + private static final int RUNNING = 1; + private static final int STOPPED = 2; + + private int status = NEW; + + public static void delete( Cleaner cleaner, File dir, String fastMode ) + { + synchronized ( BackgroundCleaner.class ) + { + if ( instance == null || !instance.doDelete( dir ) ) + { + instance = new BackgroundCleaner( cleaner, dir, fastMode ); + } + } + } + + static void sessionEnd() + { + synchronized ( BackgroundCleaner.class ) + { + if ( instance != null ) + { + instance.doSessionEnd(); + } + } + } + + private BackgroundCleaner( Cleaner cleaner, File dir, String fastMode ) + { + super( "mvn-background-cleaner" ); + this.cleaner = cleaner; + this.fastMode = fastMode; + init( cleaner.fastDir, dir ); + } + + public void run() + { + while ( true ) + { + File basedir = pollNext(); + if ( basedir == null ) + { + break; + } + try + { + cleaner.delete( basedir, "", null, false, false, true ); + } + catch ( IOException e ) + { + // do not display errors + } + } + } + + synchronized void init( File fastDir, File dir ) + { + if ( fastDir.isDirectory() ) + { + File[] children = fastDir.listFiles(); + if ( children != null && children.length > 0 ) + { + for ( File child : children ) + { + doDelete( child ); + } + } + } + doDelete( dir ); + } + + synchronized File pollNext() + { + File basedir = filesToDelete.poll(); + if ( basedir == null ) + { + if ( cleaner.session != null ) + { + SessionData data = cleaner.session.getRepositorySession().getData(); + File lastDir = ( File ) data.get( LAST_DIRECTORY_TO_DELETE ); + if ( lastDir != null ) + { + data.set( LAST_DIRECTORY_TO_DELETE, null ); + return lastDir; + } + } + status = STOPPED; + notifyAll(); + } + return basedir; + } + + synchronized boolean doDelete( File dir ) + { + if ( status == STOPPED ) + { + return false; + } + filesToDelete.add( dir ); + if ( status == NEW && FAST_MODE_BACKGROUND.equals( fastMode ) ) + { + status = RUNNING; + notifyAll(); + start(); + } + wrapExecutionListener(); + return true; + } + + /** + * If this has not been done already, we wrap the ExecutionListener inside a proxy + * which simply delegates call to the previous listener. When the session ends, it will + * also call {@link BackgroundCleaner#sessionEnd()}. + * There's no clean API to do that properly as this is a very unusual use case for a plugin + * to outlive its main execution. + */ + private void wrapExecutionListener() + { + ExecutionListener executionListener = cleaner.session.getRequest().getExecutionListener(); + if ( executionListener == null + || !Proxy.isProxyClass( executionListener.getClass() ) + || !( Proxy.getInvocationHandler( executionListener ) instanceof SpyInvocationHandler ) ) + { + ExecutionListener listener = ( ExecutionListener ) Proxy.newProxyInstance( + ExecutionListener.class.getClassLoader(), + new Class[] { ExecutionListener.class }, + new SpyInvocationHandler( executionListener ) ); + cleaner.session.getRequest().setExecutionListener( listener ); + } + } + + synchronized void doSessionEnd() + { + if ( status != STOPPED ) + { + if ( status == NEW ) + { + start(); + } + if ( !FAST_MODE_DEFER.equals( fastMode ) ) + { + try + { + cleaner.logInfo.log( "Waiting for background file deletion" ); + while ( status != STOPPED ) + { + wait(); + } + } + catch ( InterruptedException e ) + { + // ignore + } + } + } + } + + } + + static class SpyInvocationHandler implements InvocationHandler + { + private final ExecutionListener delegate; + + SpyInvocationHandler( ExecutionListener delegate ) + { + this.delegate = delegate; + } + + @Override + public Object invoke( Object proxy, Method method, Object[] args ) throws Throwable + { + if ( "sessionEnded".equals( method.getName() ) ) + { + BackgroundCleaner.sessionEnd(); + } + if ( delegate != null ) + { + return method.invoke( delegate, args ); + } + return null; + } + + } + }