Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MJARSIGNER-72] Parallel signing for increased speed #18

Merged
merged 1 commit into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.ResourceBundle;

import org.apache.maven.artifact.Artifact;
Expand Down Expand Up @@ -279,65 +280,70 @@ public final void execute() throws MojoExecutionException {
jarSigner.setToolchain(toolchain);
}

int processed = 0;
List<File> archives = findJarfiles();
processArchives(archives);
getLog().info(getMessage("processed", archives.size()));
}

/**
* Finds all jar files, by looking at the Maven project and user configuration.
*
* @return a List of File objects
* @throws MojoExecutionException if it was not possible to build a list of jar files
*/
private List<File> findJarfiles() throws MojoExecutionException {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once again the diff on GitHub displays like I have changed the entire function. View the diff from an IDE to easier see what rows I have only re-indented.

if (this.archive != null) {
processArchive(this.archive);
processed++;
} else {
if (processMainArtifact) {
processed += processArtifact(this.project.getArtifact()) ? 1 : 0;
}
// Only process this, but nothing more
return Arrays.asList(this.archive);
}

if (processAttachedArtifacts) {
Collection<String> includes = new HashSet<>();
if (includeClassifiers != null) {
includes.addAll(Arrays.asList(includeClassifiers));
}
List<File> archives = new ArrayList<>();
if (processMainArtifact) {
getFileFromArtifact(this.project.getArtifact()).ifPresent(archives::add);
}

Collection<String> excludes = new HashSet<>();
if (excludeClassifiers != null) {
excludes.addAll(Arrays.asList(excludeClassifiers));
}
if (processAttachedArtifacts) {
Collection<String> includes = new HashSet<>();
if (includeClassifiers != null) {
includes.addAll(Arrays.asList(includeClassifiers));
}

for (Artifact artifact : this.project.getAttachedArtifacts()) {
if (!includes.isEmpty() && !includes.contains(artifact.getClassifier())) {
continue;
}
Collection<String> excludes = new HashSet<>();
if (excludeClassifiers != null) {
excludes.addAll(Arrays.asList(excludeClassifiers));
}

if (excludes.contains(artifact.getClassifier())) {
continue;
}
for (Artifact artifact : this.project.getAttachedArtifacts()) {
if (!includes.isEmpty() && !includes.contains(artifact.getClassifier())) {
continue;
}

processed += processArtifact(artifact) ? 1 : 0;
if (excludes.contains(artifact.getClassifier())) {
continue;
}

getFileFromArtifact(artifact).ifPresent(archives::add);
}
} else {
if (verbose) {
getLog().info(getMessage("ignoringAttachments"));
} else {
if (verbose) {
getLog().info(getMessage("ignoringAttachments"));
} else {
getLog().debug(getMessage("ignoringAttachments"));
}
getLog().debug(getMessage("ignoringAttachments"));
}
}

if (archiveDirectory != null) {
String includeList = (includes != null) ? StringUtils.join(includes, ",") : null;
String excludeList = (excludes != null) ? StringUtils.join(excludes, ",") : null;

List<File> jarFiles;
try {
jarFiles = FileUtils.getFiles(archiveDirectory, includeList, excludeList);
} catch (IOException e) {
throw new MojoExecutionException("Failed to scan archive directory for JARs: " + e.getMessage(), e);
}
if (archiveDirectory != null) {
String includeList = (includes != null) ? StringUtils.join(includes, ",") : null;
String excludeList = (excludes != null) ? StringUtils.join(excludes, ",") : null;

for (File jarFile : jarFiles) {
processArchive(jarFile);
processed++;
}
try {
archives.addAll(FileUtils.getFiles(archiveDirectory, includeList, excludeList));
} catch (IOException e) {
throw new MojoExecutionException("Failed to scan archive directory for JARs: " + e.getMessage(), e);
}
}

getLog().info(getMessage("processed", processed));
return archives;
}

/**
Expand All @@ -358,7 +364,7 @@ public final void execute() throws MojoExecutionException {
*
* @param commandLine The {@code Commandline} to get a string representation of.
* @return The string representation of {@code commandLine}.
* @throws NullPointerException if {@code commandLine} is {@code null}.
* @throws NullPointerException if {@code commandLine} is {@code null}
*/
protected String getCommandlineInfo(final Commandline commandLine) {
if (commandLine == null) {
Expand All @@ -384,45 +390,39 @@ public String getStorepass() {
* @param artifact The artifact to check, may be <code>null</code>.
* @return <code>true</code> if the artifact looks like a ZIP file, <code>false</code> otherwise.
*/
private boolean isZipFile(final Artifact artifact) {
private static boolean isZipFile(final Artifact artifact) {
return artifact != null && artifact.getFile() != null && JarSignerUtil.isZipFile(artifact.getFile());
}

/**
* Processes a given artifact.
* Examines an Artifact and extract the File object pointing to the Artifact jar file.
*
* @param artifact The artifact to process.
* @return <code>true</code> if the artifact is a JAR and was processed, <code>false</code> otherwise.
* @throws NullPointerException if {@code artifact} is {@code null}.
* @throws MojoExecutionException if processing {@code artifact} fails.
* @param artifact the artifact to examine
* @return An Optional containing the File, or Optional.empty() if the File is not a jar file.
* @throws NullPointerException if {@code artifact} is {@code null}
*/
private boolean processArtifact(final Artifact artifact) throws MojoExecutionException {
private Optional<File> getFileFromArtifact(final Artifact artifact) {
if (artifact == null) {
throw new NullPointerException("artifact");
}

boolean processed = false;

if (isZipFile(artifact)) {
processArchive(artifact.getFile());

processed = true;
} else {
if (this.verbose) {
getLog().info(getMessage("unsupported", artifact));
} else if (getLog().isDebugEnabled()) {
getLog().debug(getMessage("unsupported", artifact));
}
return Optional.of(artifact.getFile());
}

return processed;
if (this.verbose) {
getLog().info(getMessage("unsupported", artifact));
} else if (getLog().isDebugEnabled()) {
getLog().debug(getMessage("unsupported", artifact));
}
return Optional.empty();
}

/**
* Pre-processes a given archive.
*
* @param archive The archive to process, must not be <code>null</code>.
* @throws MojoExecutionException If pre-processing failed.
* @throws MojoExecutionException if pre-processing failed
*/
protected void preProcessArchive(final File archive) throws MojoExecutionException {
// Default implementation does nothing
Expand All @@ -437,14 +437,26 @@ protected void validateParameters() throws MojoExecutionException {
// Default implementation does nothing
}

/**
* Process (sign/verify) a list of archives.
*
* @param archives list of jar files to process
* @throws MojoExecutionException if an error occurs during the processing of archives
*/
protected void processArchives(List<File> archives) throws MojoExecutionException {
for (File file : archives) {
processArchive(file);
}
}

/**
* Processes a given archive.
*
* @param archive The archive to process.
* @throws NullPointerException if {@code archive} is {@code null}.
* @throws MojoExecutionException if processing {@code archive} fails.
* @throws NullPointerException if {@code archive} is {@code null}
* @throws MojoExecutionException if processing {@code archive} fails
*/
private void processArchive(final File archive) throws MojoExecutionException {
protected final void processArchive(final File archive) throws MojoExecutionException {
if (archive == null) {
throw new NullPointerException("archive");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.Collectors;

import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
Expand Down Expand Up @@ -115,6 +122,17 @@ public class JarsignerSignMojo extends AbstractJarsignerMojo {
@Parameter(property = "jarsigner.maxRetryDelaySeconds", defaultValue = "0")
private int maxRetryDelaySeconds;

/**
* Maximum number of parallel threads to use when signing jar files. Increases performance when signing multiple jar
* files, especially when network operations are used during signing, for example when using a Time Stamp Authority
* or network based PKCS11 HSM solution for storing code signing keys. Note: the logging from the signing process
* will be interleaved, and harder to read, when using many threads.
*
* @since 3.1.0
*/
@Parameter(property = "jarsigner.threadCount", defaultValue = "1")
private int threadCount;

/** Current WaitStrategy, to allow for sleeping after a signing failure. */
private WaitStrategy waitStrategy = this::defaultWaitStrategy;

Expand Down Expand Up @@ -156,6 +174,11 @@ protected void validateParameters() throws MojoExecutionException {
getLog().warn(getMessage("invalidMaxRetryDelaySeconds", maxRetryDelaySeconds));
maxRetryDelaySeconds = 0;
}

if (threadCount < 1) {
getLog().warn(getMessage("invalidThreadCount", threadCount));
threadCount = 1;
}
}

/**
Expand All @@ -174,12 +197,42 @@ protected JarSignerRequest createRequest(File archive) throws MojoExecutionExcep
return request;
}

/**
* {@inheritDoc} Processing of files may be parallelized for increased performance.
*/
@Override
protected void processArchives(List<File> archives) throws MojoExecutionException {
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be the minimum of threadCount and the number of archives? No reason to spawn ten threads with only 5 files to sign

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice observation! I could, but I'm not sure I should, because it would make the code a bit harder to read. In the documentation for https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadPoolExecutor.html there is this text:

When a new task is submitted in method execute(Runnable), and fewer than corePoolSize threads are running, a new thread is created to handle the request, even if other worker threads are idle.

That is, for this specific case the number of threads will in practice be limited to the number of jobs I submit to the thread pool (number of jar files). Due to how Executors.newFixedThreadPool() work you might end up with a few more than needed, if the first jobs terminate very fast before the last has been submitted. But the number of threads will never be more than the amount of submitted jobs.

I have tested and verified that this behavior holds! Using threadCount=10000 and looking at the number of threads in my IDE.

List<Future<Void>> futures = archives.stream()
.map(file -> executor.submit((Callable<Void>) () -> {
processArchive(file);
return null;
}))
.collect(Collectors.toList());
try {
for (Future<Void> future : futures) {
future.get(); // Wait for completion. Result ignored, but may raise any Exception
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new MojoExecutionException("Thread interrupted while waiting for jarsigner to complete", e);
} catch (ExecutionException e) {
if (e.getCause() instanceof MojoExecutionException) {
throw (MojoExecutionException) e.getCause();
}
throw new MojoExecutionException("Error processing archives", e);
} finally {
// Shutdown of thread pool. If an Exception occurred, remaining threads will be aborted "best effort"
executor.shutdownNow();
}
}

/**
* {@inheritDoc}
*
* Will retry signing up to maxTries times if it fails.
*
* @throws MojoExecutionException If all signing attempts fail.
* @throws MojoExecutionException if all signing attempts fail
*/
@Override
protected void executeJarSigner(JarSigner jarSigner, JarSignerRequest request)
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/jarsigner.properties
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ failure = Failed executing ''{0}'' - exitcode {1,number}
archiveNotSigned = Archive ''{0}'' is not signed
invalidMaxTries = Invalid maxTries value. Was ''{0}'' but should be >= 1
invalidMaxRetryDelaySeconds = Invalid maxRetryDelaySeconds value. Was ''{0}'' but should be >= 0
invalidThreadCount = Invalid threadCount value. Was ''{0}'' but should be >= 1
Loading