From c18f3eaf92f82afb448db6a41ab24646a32c21a3 Mon Sep 17 00:00:00 2001 From: Colin Alworth Date: Mon, 14 Aug 2023 21:39:11 -0500 Subject: [PATCH] Incremental API and disk format for task caching (#210) The Task.execute() call now can examine old results and changes to inputs to make decisions about incrementally building new outputs more efficiently. Fixes #176 --- build-caching/pom.xml | 7 + .../vertispan/j2cl/build/BuildService.java | 1 - .../j2cl/build/ChangedCachedPath.java | 36 ++ .../j2cl/build/DefaultDiskCache.java | 29 +- .../com/vertispan/j2cl/build/DiskCache.java | 82 ++++- .../java/com/vertispan/j2cl/build/Input.java | 73 +++- .../j2cl/build/LocalProjectBuildCache.java | 63 ++++ .../j2cl/build/PropertyTrackingConfig.java | 9 +- .../vertispan/j2cl/build/TaskScheduler.java | 106 +++++- .../j2cl/build/TaskSummaryDiskFormat.java | 93 +++++ .../vertispan/j2cl/build/task/CachedPath.java | 7 - .../j2cl/build/task/ChangedCachedPath.java | 33 ++ .../com/vertispan/j2cl/build/task/Config.java | 13 + .../com/vertispan/j2cl/build/task/Input.java | 7 + .../j2cl/build/task/TaskContext.java | 15 +- .../j2cl/mojo/AbstractBuildMojo.java | 3 + .../j2cl/mojo/AbstractCacheMojo.java | 3 + .../com/vertispan/j2cl/mojo/BuildMojo.java | 3 +- .../com/vertispan/j2cl/mojo/TestMojo.java | 3 +- .../com/vertispan/j2cl/mojo/WatchMojo.java | 3 +- .../build/provided/ClosureBundleTask.java | 348 ++++++++++++++++-- .../j2cl/build/provided/ClosureTask.java | 2 +- .../j2cl/build/provided/StripSourcesTask.java | 48 ++- 23 files changed, 886 insertions(+), 101 deletions(-) create mode 100644 build-caching/src/main/java/com/vertispan/j2cl/build/ChangedCachedPath.java create mode 100644 build-caching/src/main/java/com/vertispan/j2cl/build/LocalProjectBuildCache.java create mode 100644 build-caching/src/main/java/com/vertispan/j2cl/build/TaskSummaryDiskFormat.java create mode 100644 build-caching/src/main/java/com/vertispan/j2cl/build/task/ChangedCachedPath.java diff --git a/build-caching/pom.xml b/build-caching/pom.xml index a6dc4bcc..3ea147dc 100644 --- a/build-caching/pom.xml +++ b/build-caching/pom.xml @@ -23,6 +23,13 @@ 0.15.0 + + + com.google.code.gson + gson + 2.8.6 + + junit diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/BuildService.java b/build-caching/src/main/java/com/vertispan/j2cl/build/BuildService.java index 868ac139..a83e559b 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/BuildService.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/BuildService.java @@ -1,7 +1,6 @@ package com.vertispan.j2cl.build; import com.vertispan.j2cl.build.impl.CollectedTaskInputs; -import com.vertispan.j2cl.build.task.BuildLog; import com.vertispan.j2cl.build.task.OutputTypes; import com.vertispan.j2cl.build.task.TaskFactory; diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/ChangedCachedPath.java b/build-caching/src/main/java/com/vertispan/j2cl/build/ChangedCachedPath.java new file mode 100644 index 00000000..2d36f46b --- /dev/null +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/ChangedCachedPath.java @@ -0,0 +1,36 @@ +package com.vertispan.j2cl.build; + +import com.vertispan.j2cl.build.task.CachedPath; + +import java.nio.file.Path; +import java.util.Optional; + +/** + * Implementation of the ChangedCachedPath interface. + */ +public class ChangedCachedPath implements com.vertispan.j2cl.build.task.ChangedCachedPath { + private final ChangeType type; + private final Path sourcePath; + private final CachedPath newIfAny; + + public ChangedCachedPath(ChangeType type, Path sourcePath, CachedPath newPath) { + this.type = type; + this.sourcePath = sourcePath; + this.newIfAny = newPath; + } + + @Override + public ChangeType changeType() { + return type; + } + + @Override + public Path getSourcePath() { + return sourcePath; + } + + @Override + public Optional getNewAbsolutePath() { + return Optional.ofNullable(newIfAny).map(CachedPath::getAbsolutePath); + } +} diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/DefaultDiskCache.java b/build-caching/src/main/java/com/vertispan/j2cl/build/DefaultDiskCache.java index 20ffb948..8afd270b 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/DefaultDiskCache.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/DefaultDiskCache.java @@ -29,28 +29,8 @@ public DefaultDiskCache(File cacheDir, Executor executor) throws IOException { } @Override - protected Path taskDir(CollectedTaskInputs inputs) { - String projectName = inputs.getProject().getKey(); - Murmur3F hash = new Murmur3F(); - - hash.update(inputs.getTaskFactory().getClass().toString().getBytes(StandardCharsets.UTF_8)); - hash.update(inputs.getTaskFactory().getTaskName().getBytes(StandardCharsets.UTF_8)); - hash.update(inputs.getTaskFactory().getVersion().getBytes(StandardCharsets.UTF_8)); - - for (Input input : inputs.getInputs()) { - input.updateHash(hash); - } - - for (Map.Entry entry : inputs.getUsedConfigs().entrySet()) { - hash.update(entry.getKey().getBytes(StandardCharsets.UTF_8)); - if (entry.getValue() == null) { - hash.update(0); - } else { - hash.update(entry.getValue().getBytes(StandardCharsets.UTF_8)); - } - } - - return cacheDir.toPath().resolve(projectName.replaceAll("[^\\-_a-zA-Z0-9.]", "-")).resolve(hash.getValueHexString() + "-" + inputs.getTaskFactory().getOutputType()); + protected Path taskDir(String projectName, String hashString, String outputType) { + return cacheDir.toPath().resolve(projectName.replaceAll("[^\\-_a-zA-Z0-9.]", "-")).resolve(hashString + "-" + outputType); } @Override @@ -72,4 +52,9 @@ protected Path logFile(Path taskDir) { protected Path outputDir(Path taskDir) { return taskDir.resolve("results"); } + + @Override + protected Path cacheSummary(Path taskDir) { + return taskDir.resolve("cacheSummary.json"); + } } diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/DiskCache.java b/build-caching/src/main/java/com/vertispan/j2cl/build/DiskCache.java index f60cc46b..8044ee92 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/DiskCache.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/DiskCache.java @@ -1,30 +1,32 @@ package com.vertispan.j2cl.build; +import com.google.gson.GsonBuilder; import com.vertispan.j2cl.build.impl.CollectedTaskInputs; import com.vertispan.j2cl.build.task.CachedPath; import io.methvin.watcher.PathUtils; import io.methvin.watcher.hashing.FileHash; import io.methvin.watcher.hashing.FileHasher; +import io.methvin.watcher.hashing.Murmur3F; import io.methvin.watchservice.MacOSXListeningWatchService; import io.methvin.watchservice.WatchablePath; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.nio.file.WatchService; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; import java.time.Instant; -import java.time.temporal.TemporalAmount; -import java.time.temporal.TemporalUnit; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executor; +import java.util.stream.Collectors; /** - * Manages the cached task inputs and outputs. + * Manages the cached task inputs and outputs, without direct knowledge of the project or task apis. */ public abstract class DiskCache { private static final boolean IS_MAC = System.getProperty("os.name").toLowerCase().contains("mac"); @@ -38,6 +40,10 @@ public CacheResult(Path taskDir) { this.taskDir = taskDir; } + public Path taskDir() { + return taskDir; + } + public Path logFile() { //TODO finish building a logger that will write to this return DiskCache.this.logFile(taskDir); @@ -54,6 +60,10 @@ public TaskOutput output() { return taskOutput; } + public Path cachedSummary() { + return DiskCache.this.cacheSummary(taskDir); + } + public void markSuccess() { markFinished(this); runningTasks.remove(taskDir); @@ -237,7 +247,9 @@ public Path getAbsolutePath() { return absoluteParent.resolve(sourcePath); } - @Override + /** + * Internal API, as this is not at this time used by any caller. + */ public FileHash getHash() { return hash; } @@ -340,16 +352,57 @@ private void deleteRecursively(Path path) throws IOException { } } - protected abstract Path taskDir(CollectedTaskInputs inputs); + private String taskSummaryContents(CollectedTaskInputs inputs) { + TaskSummaryDiskFormat src = new TaskSummaryDiskFormat(); + src.setProjectKey(inputs.getProject().getKey()); + src.setOutputType(inputs.getTaskFactory().getOutputType()); + src.setTaskImpl(inputs.getTaskFactory().getClass().getName()); + src.setTaskImplVersion(inputs.getTaskFactory().getVersion()); + + src.setInputs(inputs.getInputs().stream() + .map(Input::makeDiskFormat) + .collect(Collectors.groupingBy(i -> i.getProjectKey() + "-" + i.getOutputType())) + .values().stream() + .map(list -> { + TaskSummaryDiskFormat.InputDiskFormat result = new TaskSummaryDiskFormat.InputDiskFormat(); + result.setProjectKey(list.get(0).getProjectKey()); + result.setOutputType(list.get(0).getOutputType()); + + result.setFileHashes( + list.stream().flatMap(i -> i.getFileHashes().entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (left, right) -> { + if (left.equals(right)) { + return left; + } + throw new IllegalStateException("Two hashes for one file! " + left + " vs " + right); + })) + ); + + return result; + }) + .collect(Collectors.toUnmodifiableList())); + + src.setConfigs(inputs.getUsedConfigs()); + + return new GsonBuilder().serializeNulls().setPrettyPrinting().create().toJson(src); + } + + protected abstract Path taskDir(String projectName, String hashString, String outputType); + protected abstract Path successMarker(Path taskDir); protected abstract Path failureMarker(Path taskDir); protected abstract Path logFile(Path taskDir); protected abstract Path outputDir(Path taskDir); + protected abstract Path cacheSummary(Path taskDir); interface Listener { + /** Ready for the current listener to do the work */ void onReady(CacheResult result); + /** Someone else did it, but failed for some reason, not re-runnable */ void onFailure(CacheResult result); + /** Someone else tried to do it, but ran into an error, possibly recoverable if we try again */ void onError(Throwable throwable); + /** Someone else finished it, successfully, notify listeners */ void onSuccess(CacheResult result); } public class PendingCacheResult implements Cancelable { @@ -431,7 +484,14 @@ private synchronized void failure() { */ public void waitForTask(CollectedTaskInputs taskDetails, Listener listener) { assert taskDetails.getInputs().stream().allMatch(Input::hasContents); - final Path taskDir = taskDir(taskDetails); + + Murmur3F murmur3F = new Murmur3F(); + byte[] taskSummaryContents = taskSummaryContents(taskDetails).getBytes(StandardCharsets.UTF_8); + murmur3F.update(taskSummaryContents); + String hashString = murmur3F.getValueHexString(); + + final Path taskDir = taskDir(taskDetails.getProject().getKey(), hashString, taskDetails.getTaskFactory().getOutputType()); + PendingCacheResult cancelable = new PendingCacheResult(taskDir, listener); taskFutures.computeIfAbsent(taskDir, ignore -> Collections.newSetFromMap(new ConcurrentHashMap<>())).add(cancelable); try { @@ -449,6 +509,7 @@ public void waitForTask(CollectedTaskInputs taskDetails, Listener listener) { // caller can begin work right away Files.createDirectory(outputDir); Files.createFile(logFile(taskDir)); + Files.write(cacheSummary(taskDir), taskSummaryContents); cancelable.ready(); return; } @@ -546,4 +607,13 @@ public void markFailed(CacheResult failedResult) { } } + public Optional getCacheResult(Path taskDir) { + if (Files.exists(taskDir) || Files.exists(successMarker(taskDir))) { + CacheResult result = new CacheResult(taskDir); + knownOutputs.computeIfAbsent(taskDir, this::makeOutput); + return Optional.of(result); + } + return Optional.empty(); + } + } diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/Input.java b/build-caching/src/main/java/com/vertispan/j2cl/build/Input.java index eb51b887..1712e4de 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/Input.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/Input.java @@ -1,8 +1,7 @@ package com.vertispan.j2cl.build; -import io.methvin.watcher.hashing.Murmur3F; +import com.vertispan.j2cl.build.task.ChangedCachedPath; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.*; @@ -18,10 +17,29 @@ * interested in, and take the hash of the hashes to represent */ public class Input implements com.vertispan.j2cl.build.task.Input { + public interface BuildSpecificChanges { + List compute(); + static BuildSpecificChanges memoize(BuildSpecificChanges changes) { + return new BuildSpecificChanges() { + private List results; + @Override + public List compute() { + if (results == null) { + List computed = changes.compute(); + assert computed != null; + results = new ArrayList<>(computed); + } + + return results; + } + }; + } + } private final Project project; private final String outputType; private TaskOutput contents; + private BuildSpecificChanges buildSpecificChanges; public Input(Project project, String outputType) { this.project = project; @@ -66,6 +84,13 @@ public Collection getFilesAndHashes() { .collect(Collectors.toUnmodifiableList()); } + @Override + public Collection getChanges() { + return wrapped.getChanges().stream() + .filter(entry -> Arrays.stream(filters).anyMatch(f -> f.matches(entry.getSourcePath()))) + .collect(Collectors.toUnmodifiableList()); + } + @Override public com.vertispan.j2cl.build.task.Project getProject() { return wrapped.getProject(); @@ -103,17 +128,37 @@ public void setCurrentContents(TaskOutput contents) { /** * Internal API. * - * Updates the given hash object with the filtered file inputs - their paths and their - * hashes, so that if files are moved or changed we change the hash value, but we don't - * re-hash each file every time we ask. + * Once a task is finished, we can let it be used by other tasks as an input. In + * order for those tasks to execute incrementally, each particular Input must + * only have changed relative to the last time that its owner task ran successfully. + * Instead of being set all at once, this is re-assigned before each task actually + * executes. */ - public void updateHash(Murmur3F hash) { - for (DiskCache.CacheEntry fileAndHash : getFilesAndHashes()) { - hash.update(fileAndHash.getSourcePath().toString().getBytes(StandardCharsets.UTF_8)); - hash.update(fileAndHash.getHash().asBytes()); - } + public void setBuildSpecificChanges(BuildSpecificChanges buildSpecificChanges) { + this.buildSpecificChanges = BuildSpecificChanges.memoize(buildSpecificChanges); } + /** + * Internal API. + * + * Creates a simple payload that describes this input, so it can be written to disk. + */ + public TaskSummaryDiskFormat.InputDiskFormat makeDiskFormat() { + TaskSummaryDiskFormat.InputDiskFormat out = new TaskSummaryDiskFormat.InputDiskFormat(); + + out.setProjectKey(getProject().getKey()); + out.setOutputType(getOutputType()); + + out.setFileHashes(getFilesAndHashes().stream().collect(Collectors.toMap( + e -> e.getSourcePath().toString(), + e -> e.getHash().asString() + ))); + + return out; + } + + + @Override public Project getProject() { return project; @@ -139,6 +184,14 @@ public Collection getFilesAndHashes() { return contents.filesAndHashes(); } + @Override + public Collection getChanges() { + if (buildSpecificChanges == null) { + throw new NullPointerException("Changes not yet provided " + this); + } + return buildSpecificChanges.compute(); + } + @Override public String toString() { return "Input{" + diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/LocalProjectBuildCache.java b/build-caching/src/main/java/com/vertispan/j2cl/build/LocalProjectBuildCache.java new file mode 100644 index 00000000..9725c9a5 --- /dev/null +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/LocalProjectBuildCache.java @@ -0,0 +1,63 @@ +package com.vertispan.j2cl.build; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.Comparator; +import java.util.Optional; + +public class LocalProjectBuildCache { + private final File cacheDir; + private final DiskCache cache; + + + public LocalProjectBuildCache(File cacheDir, DiskCache cache) { + this.cacheDir = cacheDir; + this.cache = cache; + } + + public void markLocalSuccess(Project project, String task, Path taskDir) { + String timestamp = String.valueOf(System.currentTimeMillis()); + try { + Path localTaskDir = cacheDir.toPath().resolve(project.getKey().replaceAll("[^\\-_a-zA-Z0-9.]", "-")).resolve(task); + Files.createDirectories(localTaskDir); + Files.write(localTaskDir.resolve(timestamp), Collections.singleton(taskDir.toString())); + + Files.list(localTaskDir).sorted(Comparator.naturalOrder().reversed()).skip(5).forEach(old -> { + try { + Files.delete(old); + } catch (IOException e) { + // we don't care at all about failure, as long as it sometimes works. there could be races + // to delete, or someone just cleaned, etc. + e.printStackTrace(); + } + }); + } catch (IOException e) { + // ignore, cache just won't work for now + //TODO log this, the user will be irritated + e.printStackTrace(); + } + } + + public Optional getLatestResult(Project project, String task) { + try { + Path taskDir = cacheDir.toPath().resolve(project.getKey().replaceAll("[^\\-_a-zA-Z0-9.]", "-")).resolve(task); + Optional latest = Files.list(taskDir).max(Comparator.naturalOrder()); + if (latest.isPresent()) { + String path = Files.readAllLines(latest.get()).get(0); + + return cache.getCacheResult(Paths.get(path)); + } else { + return Optional.empty(); + } + } catch (IOException e) { + // Probably file doesn't exist. This seems terrible and dirty... but on the other hand, just about any + // failure to read could mean "look just go do it from scratch, since that is always possible". + return Optional.empty(); + } + } + +} diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/PropertyTrackingConfig.java b/build-caching/src/main/java/com/vertispan/j2cl/build/PropertyTrackingConfig.java index 4888103d..af8a3d4b 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/PropertyTrackingConfig.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/PropertyTrackingConfig.java @@ -77,7 +77,7 @@ private File useFileConfig(ConfigValueProvider.ConfigNode node) { } catch (IOException e) { throw new UncheckedIOException("Failed to hash file contents " + value, e); } - useKey(node.getPath(), hash); + useKey(node.getPath(), "File with hash " + hash); return value; } private void useKey(String path, String value) { @@ -227,4 +227,11 @@ public Path getWebappDirectory() { } return Paths.get(s); } + + @Override + public boolean isIncrementalEnabled() { + // TODO once we have awesome tests for this, consider skipping the cache. Could be dangerous, + // in the case of externally provided buggy tasks. + return getString("incrementalEnabled").equalsIgnoreCase("true"); + } } diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/TaskScheduler.java b/build-caching/src/main/java/com/vertispan/j2cl/build/TaskScheduler.java index 2daeacd5..fa012b49 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/TaskScheduler.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/TaskScheduler.java @@ -1,5 +1,6 @@ package com.vertispan.j2cl.build; +import com.google.gson.Gson; import com.vertispan.j2cl.build.impl.CollectedTaskInputs; import com.vertispan.j2cl.build.task.BuildLog; import com.vertispan.j2cl.build.task.OutputTypes; @@ -7,12 +8,16 @@ import com.vertispan.j2cl.build.task.TaskContext; import java.io.FileNotFoundException; -import java.nio.file.Path; +import java.io.FileReader; +import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; @@ -21,6 +26,10 @@ import java.util.function.Function; import java.util.stream.Collectors; +import static com.vertispan.j2cl.build.task.ChangedCachedPath.ChangeType.ADDED; +import static com.vertispan.j2cl.build.task.ChangedCachedPath.ChangeType.MODIFIED; +import static com.vertispan.j2cl.build.task.ChangedCachedPath.ChangeType.REMOVED; + /** * Decides how much work to do, and when. Naive implementation just has a threadpool and does as much work * as possible at a time, depth first from the tree of tasks. A smarter impl could try to do work with many @@ -32,6 +41,7 @@ public class TaskScheduler { private final Executor executor; private final DiskCache diskCache; + private final LocalProjectBuildCache buildCache; private final BuildLog buildLog; // This is technically incorrect, but covers the current use cases, and should fail loudly if this assumption @@ -50,14 +60,15 @@ public class TaskScheduler { * * Caller is responsible for shutting down the executor service - canceling ongoing work * should be supported, but presently isn't. - * - * @param executor executor to submit work to, to be performed off thread + * @param executor executor to submit work to, to be performed off thread * @param diskCache cache to read results from, and save new results to + * @param buildCache * @param buildLog log to write details to about work being performed */ - public TaskScheduler(Executor executor, DiskCache diskCache, BuildLog buildLog) { + public TaskScheduler(Executor executor, DiskCache diskCache, LocalProjectBuildCache buildCache, BuildLog buildLog) { this.executor = executor; this.diskCache = diskCache; + this.buildCache = buildCache; this.buildLog = buildLog; } @@ -211,7 +222,51 @@ private void executeTask(CollectedTaskInputs taskDetails, DiskCache.CacheResult } try { long start = System.currentTimeMillis(); - taskDetails.getTask().execute(new TaskContext(result.outputDir(), log)); + + Optional latestResult = buildCache.getLatestResult(taskDetails.getProject(), taskDetails.getTaskFactory().getOutputType()); + final TaskSummaryDiskFormat taskSummaryDiskFormat = latestResult.map(TaskScheduler.this::getTaskSummary).orElse(null); + + if (taskSummaryDiskFormat == null) { + latestResult = Optional.empty(); + } + + + // Update any existing input to reflect what has changed + if (latestResult.isPresent()) { + for (TaskSummaryDiskFormat.InputDiskFormat onDiskInput : taskSummaryDiskFormat.getInputs()) { + // if this input is not present any more, we cannot build incrementally + if (taskDetails.getInputs().stream().noneMatch(currentInput -> + currentInput.getProject().getKey().equals(onDiskInput.getProjectKey()) + && currentInput.getOutputType().equals(onDiskInput.getOutputType()) + )) { + latestResult = Optional.empty(); + } + } + for (Input input : taskDetails.getInputs()) { + input.setBuildSpecificChanges(() -> { + Optional prevInput = taskSummaryDiskFormat.getInputs().stream() + .filter(i -> i.getProjectKey().equals(input.getProject().getKey())) + .filter(i -> i.getOutputType().equals(input.getOutputType())) + .findAny(); + if (prevInput.isPresent()) { + return diff(input.getFilesAndHashes().stream().collect(Collectors.toMap(e -> e.getSourcePath().toString(), Function.identity())), prevInput.get().getFileHashes()); + } + + return input.getFilesAndHashes().stream() + .map(entry -> new ChangedCachedPath(ADDED, entry.getSourcePath(), entry)).collect(Collectors.toUnmodifiableList()); + }); + } + } else { + for (Input input : taskDetails.getInputs()) { + input.setBuildSpecificChanges(() -> + input.getFilesAndHashes().stream() + .map(entry -> new ChangedCachedPath(ADDED, entry.getSourcePath(), entry)) + .collect(Collectors.toUnmodifiableList()) + ); + } + } + + taskDetails.getTask().execute(new TaskContext(result.outputDir(), log, latestResult.map(DiskCache.CacheResult::outputDir).orElse(null))); if (Thread.currentThread().isInterrupted()) { // Tried and failed to be canceled, so even though we were successful, some files might // have been deleted. Continue deleting contents @@ -222,6 +277,7 @@ private void executeTask(CollectedTaskInputs taskDetails, DiskCache.CacheResult if (elapsedMillis > 5) { buildLog.info("Finished " + taskDetails.getDebugName() + " in " + elapsedMillis + "ms"); } + buildCache.markLocalSuccess(taskDetails.getProject(), taskDetails.getTaskFactory().getOutputType(), result.taskDir()); result.markSuccess(); } catch (Throwable exception) { @@ -324,9 +380,10 @@ private void scheduleMoreWork(DiskCache.CacheResult cacheResult) { // When something finishes, remove it from the various dependency lists and see if we can run the loop again with more work. // Presently this could be called multiple times, so we check if already removed if (tasks.complete(taskDetails)) { + TaskOutput output = cacheResult.output(); for (Input input : allInputs.computeIfAbsent(taskDetails.getAsInput(), ignore -> Collections.emptyList())) { // since we don't support running more than one thing at a time, this will not change data out from under a running task - input.setCurrentContents(cacheResult.output()); + input.setCurrentContents(output); //TODO This maybe can race with the check on line 84, and we break since the input isn't ready yet } @@ -349,7 +406,9 @@ private boolean executeFinalTask(CollectedTaskInputs taskDetails, DiskCache.Cach try { //TODO Make sure that we want to write this to _only_ the current log, and not also to any file //TODO Also be sure to write a prefix automatically - ((TaskFactory.FinalOutputTask) taskDetails.getTask()).finish(new TaskContext(cacheResult.outputDir(), buildLog)); + + // TODO also consider if lastSuccessfulPath should be null for final tasks + ((TaskFactory.FinalOutputTask) taskDetails.getTask()).finish(new TaskContext(cacheResult.outputDir(), buildLog, null)); buildLog.info("Finished final task " + taskDetails.getDebugName() + " in " + (System.currentTimeMillis() - start) + "ms"); } catch (Throwable t) { buildLog.error("FAILED " + taskDetails.getDebugName() + " in " + (System.currentTimeMillis() - start) + "ms",t); @@ -366,4 +425,37 @@ private boolean executeFinalTask(CollectedTaskInputs taskDetails, DiskCache.Cach }); }); } + + private List diff(Map currentFiles, Map previousFiles) { + List changes = new ArrayList<>(); + Set added = new HashSet<>(currentFiles.keySet()); + added.removeAll(previousFiles.keySet()); + added.forEach(newPath -> { + changes.add(new ChangedCachedPath(ADDED, Paths.get(newPath), currentFiles.get(newPath))); + }); + + Set removed = new HashSet<>(previousFiles.keySet()); + removed.removeAll(currentFiles.keySet()); + removed.forEach(removedPath -> { + changes.add(new ChangedCachedPath(REMOVED, Paths.get(removedPath), null)); + }); + + Map changed = new HashMap<>(currentFiles); + changed.keySet().removeAll(added); + changed.forEach((possiblyModifiedPath, entry) -> { + if (!entry.getHash().asString().equals(previousFiles.get(possiblyModifiedPath))) { + changes.add(new ChangedCachedPath(MODIFIED, Paths.get(possiblyModifiedPath), entry)); + } + }); + + return changes; + } + + private TaskSummaryDiskFormat getTaskSummary(DiskCache.CacheResult latestResult) { + try { + return new Gson().fromJson(new FileReader(latestResult.cachedSummary().toFile()), TaskSummaryDiskFormat.class); + } catch (FileNotFoundException ex) { + return null; + } + } } diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/TaskSummaryDiskFormat.java b/build-caching/src/main/java/com/vertispan/j2cl/build/TaskSummaryDiskFormat.java new file mode 100644 index 00000000..fe18e924 --- /dev/null +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/TaskSummaryDiskFormat.java @@ -0,0 +1,93 @@ +package com.vertispan.j2cl.build; + +import java.util.List; +import java.util.Map; + +public class TaskSummaryDiskFormat { + private String projectKey; + private String outputType; + private String taskImpl; + private String taskImplVersion; + + private List inputs; + + private Map configs; + + public String getProjectKey() { + return projectKey; + } + + public void setProjectKey(String projectKey) { + this.projectKey = projectKey; + } + + public String getOutputType() { + return outputType; + } + + public void setOutputType(String outputType) { + this.outputType = outputType; + } + + public String getTaskImpl() { + return taskImpl; + } + + public void setTaskImpl(String taskImpl) { + this.taskImpl = taskImpl; + } + + public String getTaskImplVersion() { + return taskImplVersion; + } + + public void setTaskImplVersion(String taskImplVersion) { + this.taskImplVersion = taskImplVersion; + } + + public List getInputs() { + return inputs; + } + + public void setInputs(List inputs) { + this.inputs = inputs; + } + + public Map getConfigs() { + return configs; + } + + public void setConfigs(Map configs) { + this.configs = configs; + } + + public static class InputDiskFormat { + private String projectKey; + private String outputType; + private Map fileHashes; + + public String getProjectKey() { + return projectKey; + } + + public void setProjectKey(String projectKey) { + this.projectKey = projectKey; + } + + public String getOutputType() { + return outputType; + } + + public void setOutputType(String outputType) { + this.outputType = outputType; + } + + public Map getFileHashes() { + return fileHashes; + } + + public void setFileHashes(Map fileHashes) { + this.fileHashes = fileHashes; + } + } +} diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/task/CachedPath.java b/build-caching/src/main/java/com/vertispan/j2cl/build/task/CachedPath.java index 027241de..56fdedf7 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/task/CachedPath.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/task/CachedPath.java @@ -1,7 +1,5 @@ package com.vertispan.j2cl.build.task; -import io.methvin.watcher.hashing.FileHash; - import java.nio.file.Path; /** @@ -18,9 +16,4 @@ public interface CachedPath { * The absolute path to the file on disk. */ Path getAbsolutePath(); - - /** - * The current hash of the file, can be used to diff old and new inputs to see which specific paths changed. - */ - FileHash getHash(); } diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/task/ChangedCachedPath.java b/build-caching/src/main/java/com/vertispan/j2cl/build/task/ChangedCachedPath.java new file mode 100644 index 00000000..6311b075 --- /dev/null +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/task/ChangedCachedPath.java @@ -0,0 +1,33 @@ +package com.vertispan.j2cl.build.task; + +import java.nio.file.Path; +import java.util.Optional; + +/** + * Describes a file that has changed since a previously successful invocation of this task. + * Analogous to {@link CachedPath}, except there might not be an absolute path for the + * current file, if it was deleted. + */ +public interface ChangedCachedPath { + enum ChangeType { + ADDED, + REMOVED, + MODIFIED; + } + + /** + * The type of change that took place for this path + */ + ChangeType changeType(); + + /** + * The path of this file, relative to either its old or new parent. + */ + Path getSourcePath(); + + /** + * If the file was not deleted, returns the absolute path to the "new" version + * of this file. + */ + Optional getNewAbsolutePath(); +} diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/task/Config.java b/build-caching/src/main/java/com/vertispan/j2cl/build/task/Config.java index 77c1efdb..9e40d98d 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/task/Config.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/task/Config.java @@ -48,4 +48,17 @@ public interface Config { */ Path getWebappDirectory(); + /** + * Allow tasks to know if they need to do the work to build incrementally. Tasks + * should decide for themselves what inputs they might need or what work they + * might do based on this, plus if sources of a given project are mapped or not. + *

+ * For example, if incremental is cheap, might as well always do it, if not, only + * do it if all markers suggest it is a good idea. However, if this flag is false, + * incremental should never be attempted (could be a bug in it, etc). + * + * @return true if incremental is enabled, false if it should be skipped + */ + boolean isIncrementalEnabled(); + } diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/task/Input.java b/build-caching/src/main/java/com/vertispan/j2cl/build/task/Input.java index 8d8db900..b26a6d0d 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/task/Input.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/task/Input.java @@ -23,6 +23,13 @@ public interface Input { */ Collection getFilesAndHashes(); + /** + * Public API for tasks. + * + * Gets the changed files of this input, with a path for the old and new file, and type of change. + */ + Collection getChanges(); + /** * Public API for tasks. * diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/task/TaskContext.java b/build-caching/src/main/java/com/vertispan/j2cl/build/task/TaskContext.java index 59575933..e9bb4fd1 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/task/TaskContext.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/task/TaskContext.java @@ -1,14 +1,17 @@ package com.vertispan.j2cl.build.task; import java.nio.file.Path; +import java.util.Optional; public class TaskContext implements BuildLog { private final Path path; private final BuildLog log; + private final Path lastSuccessfulPath; - public TaskContext(Path path, BuildLog log) { + public TaskContext(Path path, BuildLog log, Path lastSuccessfulPath) { this.path = path; this.log = log; + this.lastSuccessfulPath = lastSuccessfulPath; } public Path outputPath() { @@ -19,6 +22,16 @@ public BuildLog log() { return log; } + /** + * Returns the output directory from the last time this task ran, to be used to copy other unchanged + * output files rather than regenerate them. + * + * @return empty if no previous build exists, otherwise a path to the last successful output + */ + public Optional lastSuccessfulOutput() { + return Optional.ofNullable(lastSuccessfulPath); + } + @Override public void debug(String msg) { log.debug(msg); diff --git a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/AbstractBuildMojo.java b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/AbstractBuildMojo.java index 7252b905..a0c5ead1 100644 --- a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/AbstractBuildMojo.java +++ b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/AbstractBuildMojo.java @@ -85,6 +85,9 @@ public abstract class AbstractBuildMojo extends AbstractCacheMojo { @Parameter(defaultValue = "AVOID_MAVEN") private AnnotationProcessorMode annotationProcessorMode; + @Parameter(defaultValue = "false", property = "j2cl.incremental") + private boolean incrementalEnabled; + private List defaultDependencyReplacements = Arrays.asList( new DependencyReplacement("com.google.jsinterop:base", "com.vertispan.jsinterop:base:" + Versions.VERTISPAN_JSINTEROP_BASE_VERSION), new DependencyReplacement("org.realityforge.com.google.jsinterop:base", "com.vertispan.jsinterop:base:" + Versions.VERTISPAN_JSINTEROP_BASE_VERSION), diff --git a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/AbstractCacheMojo.java b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/AbstractCacheMojo.java index dc539aaf..d30cbe68 100644 --- a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/AbstractCacheMojo.java +++ b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/AbstractCacheMojo.java @@ -16,6 +16,9 @@ public abstract class AbstractCacheMojo extends AbstractMojo { @Parameter(defaultValue = "${project.build.directory}/gwt3BuildCache", required = true, property = "gwt3.cache.dir") private File gwt3BuildCacheDir; + @Parameter(defaultValue = "${project.build.directory}/j2cl-maven-plugin-local-cache", required = true) + protected File localBuildCache; + protected Path getCacheDir() { PluginDescriptor pluginDescriptor = (PluginDescriptor) getPluginContext().get("pluginDescriptor"); String pluginVersion = pluginDescriptor.getVersion(); diff --git a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/BuildMojo.java b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/BuildMojo.java index b1eb448e..755a5a5d 100644 --- a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/BuildMojo.java +++ b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/BuildMojo.java @@ -4,6 +4,7 @@ import com.vertispan.j2cl.build.BuildService; import com.vertispan.j2cl.build.DefaultDiskCache; import com.vertispan.j2cl.build.DiskCache; +import com.vertispan.j2cl.build.LocalProjectBuildCache; import com.vertispan.j2cl.build.Project; import com.vertispan.j2cl.build.TaskRegistry; import com.vertispan.j2cl.build.TaskScheduler; @@ -231,7 +232,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { addShutdownHook(executor, diskCache); MavenLog mavenLog = new MavenLog(getLog()); - TaskScheduler taskScheduler = new TaskScheduler(executor, diskCache, mavenLog); + TaskScheduler taskScheduler = new TaskScheduler(executor, diskCache, new LocalProjectBuildCache(localBuildCache, diskCache), mavenLog); TaskRegistry taskRegistry = createTaskRegistry(); diff --git a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/TestMojo.java b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/TestMojo.java index 1f6d95ef..001d4448 100644 --- a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/TestMojo.java +++ b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/TestMojo.java @@ -9,6 +9,7 @@ import com.vertispan.j2cl.build.DefaultDiskCache; import com.vertispan.j2cl.build.Dependency; import com.vertispan.j2cl.build.DiskCache; +import com.vertispan.j2cl.build.LocalProjectBuildCache; import com.vertispan.j2cl.build.Project; import com.vertispan.j2cl.build.PropertyTrackingConfig; import com.vertispan.j2cl.build.TaskRegistry; @@ -335,7 +336,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { addShutdownHook(executor, diskCache); MavenLog mavenLog = new MavenLog(getLog()); - TaskScheduler taskScheduler = new TaskScheduler(executor, diskCache, mavenLog); + TaskScheduler taskScheduler = new TaskScheduler(executor, diskCache, new LocalProjectBuildCache(localBuildCache, diskCache), mavenLog); TaskRegistry taskRegistry = createTaskRegistry(); // Given these, build the graph of work we need to complete to get the list of tests diff --git a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/WatchMojo.java b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/WatchMojo.java index 7e3e768b..383562c8 100644 --- a/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/WatchMojo.java +++ b/j2cl-maven-plugin/src/main/java/com/vertispan/j2cl/mojo/WatchMojo.java @@ -3,6 +3,7 @@ import com.vertispan.j2cl.build.BuildService; import com.vertispan.j2cl.build.DefaultDiskCache; import com.vertispan.j2cl.build.DiskCache; +import com.vertispan.j2cl.build.LocalProjectBuildCache; import com.vertispan.j2cl.build.Project; import com.vertispan.j2cl.build.TaskRegistry; import com.vertispan.j2cl.build.TaskScheduler; @@ -196,7 +197,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { addShutdownHook(executor, diskCache); MavenLog mavenLog = new MavenLog(getLog()); - TaskScheduler taskScheduler = new TaskScheduler(executor, diskCache, mavenLog); + TaskScheduler taskScheduler = new TaskScheduler(executor, diskCache, new LocalProjectBuildCache(localBuildCache, diskCache), mavenLog); // TODO support individual task registries per execution TaskRegistry taskRegistry = createTaskRegistry(); diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureBundleTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureBundleTask.java index 57eec15c..5b4a3c4e 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureBundleTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureBundleTask.java @@ -1,24 +1,53 @@ package com.vertispan.j2cl.build.provided; import com.google.auto.service.AutoService; -import com.google.javascript.jscomp.CompilationLevel; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.reflect.TypeToken; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.javascript.jscomp.Compiler; +import com.google.javascript.jscomp.CompilerInput; import com.google.javascript.jscomp.CompilerOptions; -import com.google.javascript.jscomp.DependencyOptions; -import com.vertispan.j2cl.build.task.*; -import io.methvin.watcher.hashing.Murmur3F; +import com.google.javascript.jscomp.SourceFile; +import com.google.javascript.jscomp.deps.ClosureBundler; +import com.google.javascript.jscomp.deps.DependencyInfo; +import com.google.javascript.jscomp.deps.ModuleLoader; +import com.google.javascript.jscomp.deps.SortedDependencies; +import com.google.javascript.jscomp.parsing.parser.FeatureSet; +import com.google.javascript.jscomp.transpile.BaseTranspiler; +import com.google.javascript.jscomp.transpile.Transpiler; +import com.vertispan.j2cl.build.task.CachedPath; +import com.vertispan.j2cl.build.task.ChangedCachedPath; +import com.vertispan.j2cl.build.task.Config; +import com.vertispan.j2cl.build.task.Input; +import com.vertispan.j2cl.build.task.OutputTypes; +import com.vertispan.j2cl.build.task.Project; +import com.vertispan.j2cl.build.task.TaskFactory; import com.vertispan.j2cl.tools.Closure; +import io.methvin.watcher.hashing.Murmur3F; import org.apache.commons.io.FileUtils; import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.File; +import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Optional; +import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -61,6 +90,11 @@ public Task resolve(Project project, Config config) { .collect(Collectors.toUnmodifiableList()); } + // Consider treating this always as true, since the build doesnt get more costly to be incremental + boolean incrementalEnabled = config.isIncrementalEnabled(); + + Gson gson = new GsonBuilder().setPrettyPrinting().serializeNulls().create(); + return context -> { assert Files.isDirectory(context.outputPath()); File closureOutputDir = context.outputPath().toFile(); @@ -71,50 +105,124 @@ public Task resolve(Project project, Config config) { String outputFile = closureOutputDir + "/" + fileNameKey + ".js"; Path outputFilePath = Paths.get(outputFile); - if (!js.stream().map(Input::getFilesAndHashes).flatMap(Collection::stream).findAny().isPresent()) { + if (js.stream().map(Input::getFilesAndHashes).flatMap(Collection::stream).findAny().isEmpty()) { // if there are no js sources, write an empty file and exit Files.createFile(outputFilePath); return;// nothing to do } - Closure closureCompiler = new Closure(context); - // copy the sources locally so that we can create usable sourcemaps //TODO consider a soft link File sources = new File(closureOutputDir, Closure.SOURCES_DIRECTORY_NAME); - for (Path path : js.stream().map(Input::getParentPaths).flatMap(Collection::stream).collect(Collectors.toList())) { + for (Path path : js.stream().map(Input::getParentPaths).flatMap(Collection::stream).collect(Collectors.toUnmodifiableList())) { FileUtils.copyDirectory(path.toFile(), sources); } - // create the JS bundle, only ordering these files - boolean success = closureCompiler.compile( - CompilationLevel.BUNDLE, - DependencyOptions.DependencyMode.SORT_ONLY, - CompilerOptions.LanguageMode.NO_TRANSPILE, - Collections.singletonMap( - sources.getAbsolutePath(), - js.stream() - .map(Input::getFilesAndHashes) + List dependencyInfos = new ArrayList<>(); + Compiler jsCompiler = new Compiler(System.err);//TODO before merge, write this to the log + + if (incrementalEnabled && context.lastSuccessfulOutput().isPresent()) { + // collect any dep info from disk for existing files + final Map depInfoMap; + Path lastOutput = context.lastSuccessfulOutput().get(); + try (InputStream inputStream = Files.newInputStream(lastOutput.resolve("depInfo.json"))) { + Type listType = new TypeToken>() { + }.getType(); + List deps = gson.fromJson(new BufferedReader(new InputStreamReader(inputStream)), listType); + depInfoMap = deps.stream() + .map(info -> new DependencyInfoAndSource( + info, + () -> Files.readString(lastOutput.resolve(Closure.SOURCES_DIRECTORY_NAME).resolve(info.getName()))) + ) + .collect(Collectors.toMap(DependencyInfo::getName, Function.identity())); + } + + // create new dep info for any added/modified file + for (Input jsInput : js) { + for (ChangedCachedPath change : jsInput.getChanges()) { + if (change.changeType() == ChangedCachedPath.ChangeType.REMOVED) { + depInfoMap.remove(change.getSourcePath().toString()); + } else { + // ADD or MODIFY + CompilerInput input = new CompilerInput(SourceFile.builder() + .withPath(context.outputPath().resolve(Closure.SOURCES_DIRECTORY_NAME).resolve(change.getSourcePath())) + .withOriginalPath(change.getSourcePath().toString()) + .build()); + input.setCompiler(jsCompiler); + depInfoMap.put( + change.getSourcePath().toString(), + new DependencyInfoAndSource(input, input::getCode) + ); + } + } + } + + // no need to expand to include other files, since this is only computed locally + + // assign the dep info and sources we have + dependencyInfos.addAll(depInfoMap.values()); + } else { + //non-incremental, read everything + for (Input jsInput : js) { + for (CachedPath path : jsInput.getFilesAndHashes()) { + CompilerInput input = new CompilerInput(SourceFile.builder() + .withPath(context.outputPath().resolve(Closure.SOURCES_DIRECTORY_NAME).resolve(path.getSourcePath())) + .withOriginalPath(path.getSourcePath().toString()) + .build()); + input.setCompiler(jsCompiler); + + dependencyInfos.add(new DependencyInfoAndSource(input, input::getCode)); + } + } + } + + // re-sort that full collection + SortedDependencies sorter = new SortedDependencies<>(dependencyInfos); + + + // TODO optional/stretch-goal find first change in the list, so we can keep old prefix of bundle output + + // rebundle all (optional: remaining) files using this already handled sort + ClosureBundler bundler = new ClosureBundler(Transpiler.NULL, new BaseTranspiler( + new BaseTranspiler.CompilerSupplier( + CompilerOptions.LanguageMode.ECMASCRIPT_NEXT.toFeatureSet().without(FeatureSet.Feature.MODULES), + ModuleLoader.ResolutionMode.BROWSER, + ImmutableList.copyOf(js.stream() + .map(Input::getParentPaths) .flatMap(Collection::stream) - .map(CachedPath::getSourcePath) .map(Path::toString) - .collect(Collectors.toUnmodifiableList()) + .collect(Collectors.toUnmodifiableList())), + ImmutableMap.of() ), - sources, - Collections.emptyList(), - Collections.emptyMap(), - Collections.emptyList(),//TODO actually pass these in when we can restrict and cache them sanely - Optional.empty(), - true,//TODO have this be passed in, - true,//default to true, will have no effect anyway - false, - false, - "CUSTOM", // doesn't matter, bundle won't check this - outputFile - ); - - if (!success) { - throw new IllegalStateException("Closure Compiler failed, check log for details"); + "" + )).useEval(true); + + try (OutputStream outputStream = Files.newOutputStream(Paths.get(outputFile)); + BufferedWriter bundleOut = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { + for (DependencyInfoAndSource info : sorter.getSortedList()) { + String code = info.getSource(); + String name = info.getName(); + + //TODO do we actually need this? + if (Compiler.isFillFileName(name) && code.isEmpty()) { + continue; + } + + // append this file and a comment where it came from + bundleOut.append("//").append(name).append("\n"); + bundler.withPath(name).withSourceUrl(Closure.SOURCES_DIRECTORY_NAME + "/" + name).appendTo(bundleOut, info, code); + bundleOut.append("\n"); + + } + + } + // append dependency info to deserialize on some incremental rebuild + try (OutputStream outputStream = Files.newOutputStream(context.outputPath().resolve("depInfo.json")); + BufferedWriter jsonOut = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { + List jsonList = sorter.getSortedList().stream() + .map(DependencyInfoFormat::new) + .collect(Collectors.toUnmodifiableList()); + gson.toJson(jsonList, jsonOut); } // hash the file itself, rename to include that hash @@ -129,4 +237,174 @@ public Task resolve(Project project, Config config) { //TODO when back to keyboard rename sourcemap? is that a thing we need to do? }; } + + public interface SourceSupplier { + String get() throws IOException; + } + public static class DependencyInfoAndSource extends DependencyInfo.Base { + private final DependencyInfo delegate; + private final SourceSupplier sourceSupplier; + + public DependencyInfoAndSource(DependencyInfo delegate, SourceSupplier sourceSupplier) { + this.delegate = delegate; + this.sourceSupplier = sourceSupplier; + } + + public String getSource() throws IOException { + return sourceSupplier.get(); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public String getPathRelativeToClosureBase() { + return delegate.getPathRelativeToClosureBase(); + } + + @Override + public ImmutableList getProvides() { + return delegate.getProvides(); + } + + @Override + public ImmutableList getRequires() { + return delegate.getRequires(); + } + + @Override + public ImmutableList getRequiredSymbols() { + //deliberately overriding the base impl + return delegate.getRequiredSymbols(); + } + + @Override + public ImmutableList getTypeRequires() { + return delegate.getTypeRequires(); + } + + @Override + public ImmutableMap getLoadFlags() { + return delegate.getLoadFlags(); + } + + @Override + public boolean isModule() { + return delegate.isModule(); + } + + @Override + public boolean isEs6Module() { + return delegate.isEs6Module(); + } + + @Override + public boolean isGoogModule() { + return delegate.isGoogModule(); + } + + @Override + public boolean getHasExternsAnnotation() { + return delegate.getHasExternsAnnotation(); + } + + @Override + public boolean getHasNoCompileAnnotation() { + return delegate.getHasNoCompileAnnotation(); + } + } + + public static class DependencyInfoFormat extends DependencyInfo.Base { + private String name; +// private String pathRelativeToClosureBase = name; + private List provides; +// private List requires; //skipping requires as it isnt used by the dep sorter + private List requiredSymbols; + private List typeRequires; + private Map loadFlags; + private boolean hasExternsAnnotation; + private boolean hasNoCompileAnnotation; + + public DependencyInfoFormat() { + + } + + public DependencyInfoFormat(DependencyInfo info) { + setName(info.getName()); + setHasExternsAnnotation(info.getHasExternsAnnotation()); + setHasNoCompileAnnotation(info.getHasExternsAnnotation()); + setProvides(info.getProvides()); + setLoadFlags(info.getLoadFlags()); + setTypeRequires(info.getTypeRequires()); + setRequiredSymbols(info.getRequiredSymbols()); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getPathRelativeToClosureBase() { + return getName(); + } + + public ImmutableList getProvides() { + return ImmutableList.copyOf(provides); + } + + public void setProvides(List provides) { + this.provides = provides; + } + + public ImmutableList getRequires() { + return ImmutableList.of(); + } + + @Override + public ImmutableList getRequiredSymbols() { + return ImmutableList.copyOf(requiredSymbols); + } + + public void setRequiredSymbols(List requiredSymbols) { + this.requiredSymbols = requiredSymbols; + } + + public ImmutableList getTypeRequires() { + return ImmutableList.copyOf(typeRequires); + } + + public void setTypeRequires(List typeRequires) { + this.typeRequires = typeRequires; + } + + public ImmutableMap getLoadFlags() { + return ImmutableMap.copyOf(loadFlags); + } + + public void setLoadFlags(Map loadFlags) { + this.loadFlags = loadFlags; + } + + public boolean getHasExternsAnnotation() { + return hasExternsAnnotation; + } + + public void setHasExternsAnnotation(boolean hasExternsAnnotation) { + this.hasExternsAnnotation = hasExternsAnnotation; + } + + public boolean getHasNoCompileAnnotation() { + return hasNoCompileAnnotation; + } + + public void setHasNoCompileAnnotation(boolean hasNoCompileAnnotation) { + this.hasNoCompileAnnotation = hasNoCompileAnnotation; + } + } } diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureTask.java index afa6ac01..93f6e957 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureTask.java @@ -234,7 +234,7 @@ public void execute(TaskContext context) throws Exception { js = Closure.mapFromInputs(jsSources); } if (sources != null) { - for (Path path : jsSources.stream().map(Input::getParentPaths).flatMap(Collection::stream).collect(Collectors.toList())) { + for (Path path : jsSources.stream().map(Input::getParentPaths).flatMap(Collection::stream).collect(Collectors.toUnmodifiableList())) { FileUtils.copyDirectory(path.toFile(), sources); } } diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/StripSourcesTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/StripSourcesTask.java index ea71d910..5e4aab6d 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/StripSourcesTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/StripSourcesTask.java @@ -5,14 +5,18 @@ import com.vertispan.j2cl.build.task.*; import com.vertispan.j2cl.tools.GwtIncompatiblePreprocessor; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.Stream; @AutoService(TaskFactory.class) public class StripSourcesTask extends TaskFactory { public static final PathMatcher JAVA_SOURCES = withSuffix(".java"); - public static final PathMatcher NATIVE_JS_SOURCES = withSuffix(".native.js"); @Override public String getOutputType() { @@ -37,12 +41,42 @@ public Task resolve(Project project, Config config) { if (inputSources.getFilesAndHashes().isEmpty()) { return;// nothing to do } + List filesToProcess = new ArrayList<>(); + if (context.lastSuccessfulOutput().isPresent()) { + Map unmodified = inputSources.getFilesAndHashes().stream().collect(Collectors.toMap( + CachedPath::getSourcePath, + Function.identity() + )); + //only process changed files, copy unchanged ones + for (ChangedCachedPath change : inputSources.getChanges()) { + // remove the file, since it was changed in some way + unmodified.remove(change.getSourcePath()); + + if (change.changeType() != ChangedCachedPath.ChangeType.REMOVED) { + // track the files we actually need to process + filesToProcess.add(makeFileInfo(change)); + } + } + for (CachedPath path : unmodified.values()) { + Files.createDirectories(context.outputPath().resolve(path.getSourcePath()).getParent()); + Files.copy(context.lastSuccessfulOutput().get().resolve(path.getSourcePath()), context.outputPath().resolve(path.getSourcePath())); + } + } else { + for (CachedPath path : inputSources.getFilesAndHashes()) { + filesToProcess.add(makeFileInfo(path)); + } + } GwtIncompatiblePreprocessor preprocessor = new GwtIncompatiblePreprocessor(context.outputPath().toFile(), context); - preprocessor.preprocess( - inputSources.getFilesAndHashes().stream() - .map(p -> SourceUtils.FileInfo.create(p.getAbsolutePath().toString(), p.getSourcePath().toString())) - .collect(Collectors.toUnmodifiableList()) - ); + preprocessor.preprocess(filesToProcess); }; } + + private SourceUtils.FileInfo makeFileInfo(ChangedCachedPath change) { + assert change.getNewAbsolutePath().isPresent() : "Can't make a FileInfo if it no longer exists"; + return SourceUtils.FileInfo.create(change.getNewAbsolutePath().get().toString(), change.getSourcePath().toString()); + } + + private SourceUtils.FileInfo makeFileInfo(CachedPath path) { + return SourceUtils.FileInfo.create(path.getAbsolutePath().toString(), path.getSourcePath().toString()); + } }