Skip to content

Commit 8ec66cd

Browse files
committed
Support SNAPSHOTs and fix dependency cache
We will no longer fail when a SNAPSHOT dependency is used. In order to make sure that SNAPSHOTs are periodically re-resolved, a TTL of 1 day was added to the cache file so that the cache is invalidated every 24 hours. The cache can be manually invalidated using `smithy clean`. When implementing a TTL, I noticed that caching wasn't actually working and was always invalidated. That's fixed now by using the correct timestamp comparisons, and several new test cases were added. Closes #1850
1 parent ca7f93c commit 8ec66cd

File tree

4 files changed

+125
-41
lines changed

4 files changed

+125
-41
lines changed

smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/FileCacheResolver.java

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
import java.nio.file.Files;
2323
import java.nio.file.Path;
2424
import java.nio.file.Paths;
25+
import java.time.Duration;
2526
import java.util.ArrayList;
2627
import java.util.Collections;
28+
import java.util.Date;
2729
import java.util.List;
2830
import java.util.Map;
2931
import java.util.logging.Logger;
@@ -39,6 +41,9 @@
3941
*/
4042
public final class FileCacheResolver implements DependencyResolver {
4143

44+
// This is hard-coded for now to 1 day, and it can become an environment variable in the future if needed.
45+
private static final Duration SMITHY_MAVEN_TTL = Duration.parse("P1D");
46+
4247
private static final Logger LOGGER = Logger.getLogger(FileCacheResolver.class.getName());
4348
private final DependencyResolver delegate;
4449
private final File location;
@@ -80,31 +85,45 @@ public List<ResolvedArtifact> resolve() {
8085
}
8186

8287
private List<ResolvedArtifact> load() {
83-
// Invalidate the cache if smithy-build.json was updated after the cache was written.
84-
Path filePath = location.toPath();
85-
if (!Files.exists(filePath)) {
88+
// Invalidate the cache if:
89+
// 1. smithy-build.json was updated after the cache was written.
90+
// 2. the cache file is older than the allowed TTL.
91+
// 3. a cached artifact was deleted.
92+
// 4. a cached artifact is newer than the cache file.
93+
long cacheLastModifiedMillis = location.lastModified();
94+
long currentTimeMillis = new Date().getTime();
95+
long ttlMillis = SMITHY_MAVEN_TTL.toMillis();
96+
97+
if (location.length() == 0) {
8698
return Collections.emptyList();
87-
} else if (!isCacheValid(location)) {
88-
invalidate(filePath);
99+
} else if (!isCacheValid(cacheLastModifiedMillis)) {
100+
LOGGER.fine("Invalidating dependency cache: config is newer than the cache");
101+
invalidate();
102+
return Collections.emptyList();
103+
} else if (currentTimeMillis - cacheLastModifiedMillis > ttlMillis) {
104+
LOGGER.fine(() -> "Invalidating dependency cache: Cache exceeded TTL (TTL: " + ttlMillis + ")");
105+
invalidate();
89106
return Collections.emptyList();
90107
}
91108

92109
ObjectNode node;
93-
try (InputStream stream = Files.newInputStream(filePath)) {
110+
try (InputStream stream = Files.newInputStream(location.toPath())) {
94111
node = Node.parse(stream, location.toString()).expectObjectNode();
95112
} catch (ModelSyntaxException | IOException e) {
96-
throw new DependencyResolverException("Error loading dependency cache file from " + filePath, e);
113+
throw new DependencyResolverException("Error loading dependency cache file from " + location, e);
97114
}
98115

99116
List<ResolvedArtifact> result = new ArrayList<>(node.getStringMap().size());
100117
for (Map.Entry<String, Node> entry : node.getStringMap().entrySet()) {
101-
Path location = Paths.get(entry.getValue().expectStringNode().getValue());
102-
// Invalidate the cache if the JAR file was updated after the cache was written.
103-
if (isArtifactUpdatedSinceReferenceTime(location)) {
104-
invalidate(filePath);
118+
Path artifactLocation = Paths.get(entry.getValue().expectStringNode().getValue());
119+
long lastModifiedOfArtifact = artifactLocation.toFile().lastModified();
120+
// Invalidate the cache if the JAR file was updated since the cache was created.
121+
if (lastModifiedOfArtifact == 0 || lastModifiedOfArtifact > cacheLastModifiedMillis) {
122+
LOGGER.fine(() -> "Invalidating dependency cache: artifact is newer than cache: " + artifactLocation);
123+
invalidate();
105124
return Collections.emptyList();
106125
}
107-
result.add(ResolvedArtifact.fromCoordinates(location, entry.getKey()));
126+
result.add(ResolvedArtifact.fromCoordinates(artifactLocation, entry.getKey()));
108127
}
109128

110129
return result;
@@ -130,23 +149,13 @@ private void save(List<ResolvedArtifact> result) {
130149
}
131150
}
132151

133-
private boolean isCacheValid(File file) {
134-
return referenceTimeInMillis <= file.lastModified() && file.length() > 0;
135-
}
136-
137-
private boolean isArtifactUpdatedSinceReferenceTime(Path path) {
138-
File file = path.toFile();
139-
return !file.exists() || (referenceTimeInMillis > 0 && file.lastModified() > referenceTimeInMillis);
152+
private boolean isCacheValid(long cacheLastModifiedMillis) {
153+
return referenceTimeInMillis <= cacheLastModifiedMillis;
140154
}
141155

142-
private void invalidate(Path filePath) {
143-
try {
144-
if (Files.exists(filePath)) {
145-
LOGGER.fine("Invalidating dependency cache file: " + location);
146-
Files.delete(filePath);
147-
}
148-
} catch (IOException e) {
149-
throw new DependencyResolverException("Unable to delete cache file: " + e.getMessage(), e);
156+
private void invalidate() {
157+
if (location.exists() && !location.delete()) {
158+
LOGGER.warning("Unable to invalidate dependency cache file: " + location);
150159
}
151160
}
152161
}

smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/MavenDependencyResolver.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,6 @@ private static Dependency createDependency(String coordinates, String scope) {
152152
} catch (IllegalArgumentException e) {
153153
throw new DependencyResolverException("Invalid dependency: " + e.getMessage());
154154
}
155-
if (artifact.isSnapshot()) {
156-
throw new DependencyResolverException("Snapshot dependencies are not supported: " + artifact);
157-
}
158155
validateDependencyVersion(artifact);
159156
return new Dependency(artifact, scope);
160157
}

smithy-cli/src/test/java/software/amazon/smithy/cli/dependencies/FileCacheResolverTest.java

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
import java.io.IOException;
1212
import java.nio.charset.StandardCharsets;
1313
import java.nio.file.Files;
14+
import java.time.Duration;
1415
import java.util.ArrayList;
16+
import java.util.Date;
1517
import java.util.List;
18+
import java.util.function.Consumer;
1619
import org.junit.jupiter.api.Test;
1720
import software.amazon.smithy.build.model.MavenRepository;
1821
import software.amazon.smithy.utils.IoUtils;
@@ -48,38 +51,113 @@ public void ignoresAndDeletesEmptyCacheFiles() throws IOException {
4851
}
4952

5053
@Test
51-
public void loadsCacheFromDelegateWhenCacheMissingAndSaves() throws IOException {
54+
public void invalidatesCacheWhenArtifactDeleted() throws IOException {
55+
// Delete the "JAR" to invalidate the cache.
56+
validateCacheScenario(File::delete);
57+
}
58+
59+
@Test
60+
public void invalidatesCacheWhenArtifactIsNewerThanCache() throws IOException {
61+
// Set the last modified time of the "JAR" to the future to ensure the cache is invalidated.
62+
validateCacheScenario(jar -> jar.setLastModified(new Date().getTime() + Duration.parse("P1D").toMillis()));
63+
}
64+
65+
private void validateCacheScenario(Consumer<File> jarFileMutation) throws IOException {
5266
File cache = File.createTempFile("classpath", ".json");
53-
File jar = File.createTempFile("foo", ".json");
67+
File jar = File.createTempFile("foo", ".jar");
5468
Files.write(jar.toPath(), "{}".getBytes(StandardCharsets.UTF_8));
5569

5670
ResolvedArtifact artifact = ResolvedArtifact.fromCoordinates(jar.toPath(), "com.foo:bar:1.0.0");
5771
List<ResolvedArtifact> result = new ArrayList<>();
5872
result.add(artifact);
5973

6074
Mock mock = new Mock(result);
61-
DependencyResolver resolver = new FileCacheResolver(cache, jar.lastModified(), mock);
62-
List<ResolvedArtifact> resolved = resolver.resolve();
75+
DependencyResolver cachingResolver = new FileCacheResolver(cache, jar.lastModified(), mock);
76+
List<ResolvedArtifact> resolved = cachingResolver.resolve();
6377

78+
// Make sure artifacts were cached as expected.
6479
assertThat(resolved, contains(artifact));
6580
assertThat(IoUtils.readUtf8File(cache.toPath()), containsString("com.foo:bar:1.0.0"));
6681

67-
// Remove the canned entry from the mock to ensure the cache is working before delegating.
82+
// Remove the canned entry from the mock so that when the cache is invalidated, we get a different result.
6883
result.clear();
6984

70-
// Calling it again will load from the cached file.
71-
assertThat(resolver.resolve(), contains(artifact));
85+
// Calling it again will load from the cached file and not from the delegate mock that's now empty.
86+
assertThat(cachingResolver.resolve(), contains(artifact));
7287

7388
// The cache should still be there.
7489
assertThat(IoUtils.readUtf8File(cache.toPath()), containsString("com.foo:bar:1.0.0"));
7590

76-
// Removing the cache artifact invalidates the cache.
77-
assertThat(jar.delete(), is(true));
91+
// Mutate the JAR using the provided method. This method should invalidate the cache.
92+
jarFileMutation.accept(jar);
7893

79-
assertThat(resolver.resolve(), empty());
94+
// Resolving here skips the cache (which contains artifacts) and calls the delegate (which is now empty).
95+
assertThat(cachingResolver.resolve(), empty());
96+
97+
// The caching resolver should now write an empty cache file.
8098
assertThat(IoUtils.readUtf8File(cache.toPath()), containsString("{}"));
8199
}
82100

101+
@Test
102+
public void invalidatesCacheWhenConfigIsNewerThanCache() throws IOException {
103+
File cache = File.createTempFile("classpath", ".json");
104+
File jar = File.createTempFile("foo", ".jar");
105+
Files.write(jar.toPath(), "{}".getBytes(StandardCharsets.UTF_8));
106+
107+
ResolvedArtifact artifact = ResolvedArtifact.fromCoordinates(jar.toPath(), "com.foo:bar:1.0.0");
108+
List<ResolvedArtifact> result = new ArrayList<>();
109+
result.add(artifact);
110+
111+
Mock mock = new Mock(result);
112+
// Set the "config" last modified to a future date to ensure it's newer than the "JAR" file.
113+
DependencyResolver cachingResolver = new FileCacheResolver(
114+
cache,
115+
jar.lastModified() + Duration.parse("P1D").toMillis(),
116+
mock
117+
);
118+
List<ResolvedArtifact> resolved = cachingResolver.resolve();
119+
120+
// Make sure artifacts were cached as expected.
121+
assertThat(resolved, contains(artifact));
122+
assertThat(IoUtils.readUtf8File(cache.toPath()), containsString("com.foo:bar:1.0.0"));
123+
124+
// Remove the canned entry from the mock so that when the cache is invalidated, we get a different result.
125+
result.clear();
126+
127+
// The cache will be invalidated here and reloaded from source, resulting in an empty result.
128+
assertThat(cachingResolver.resolve(), empty());
129+
}
130+
131+
@Test
132+
public void invalidatesCacheWhenCacheExceedsTTL() throws IOException {
133+
long tenDaysAgo = new Date().getTime() - Duration.parse("P10D").toMillis();
134+
File cache = File.createTempFile("classpath", ".json");
135+
File jar = File.createTempFile("foo", ".jar");
136+
Files.write(jar.toPath(), "{}".getBytes(StandardCharsets.UTF_8));
137+
138+
ResolvedArtifact artifact = ResolvedArtifact.fromCoordinates(jar.toPath(), "com.foo:bar:1.0.0");
139+
List<ResolvedArtifact> result = new ArrayList<>();
140+
result.add(artifact);
141+
142+
Mock mock = new Mock(result);
143+
// Make sure the config is set to 10 days ago too, so that config date checking doesn't invalidate.
144+
DependencyResolver cachingResolver = new FileCacheResolver(cache, tenDaysAgo, mock);
145+
List<ResolvedArtifact> resolved = cachingResolver.resolve();
146+
147+
// Make sure artifacts were cached as expected.
148+
assertThat(resolved, contains(artifact));
149+
assertThat(IoUtils.readUtf8File(cache.toPath()), containsString("com.foo:bar:1.0.0"));
150+
151+
// Remove the canned entry from the mock so that when the cache is invalidated, we get a different result.
152+
result.clear();
153+
154+
// Change the last modified of the cache to a date in the distant past to invalidate the cache.
155+
assertThat(cache.setLastModified(tenDaysAgo), is(true));
156+
157+
// The cache will be invalidated here and reloaded from source, resulting in an empty result.
158+
assertThat(cachingResolver.resolve(), empty());
159+
}
160+
83161
private static final class Mock implements DependencyResolver {
84162
final List<ResolvedArtifact> artifacts;
85163
final List<MavenRepository> repositories = new ArrayList<>();

smithy-cli/src/test/java/software/amazon/smithy/cli/dependencies/MavenDependencyResolverTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public void allowsValidDependenciesAndRepos() {
2121
resolver.addDependency("com.foo:baz1:1.0.0");
2222
resolver.addDependency("com.foo:baz2:[1.0.0]");
2323
resolver.addDependency("com.foo:baz3:[1.0.0,]");
24+
resolver.addDependency("smithy.foo:bar:1.25.0-SNAPSHOT");
2425
}
2526

2627
@ParameterizedTest
@@ -36,7 +37,6 @@ public void validatesDependencies(String value) {
3637
public static Stream<Arguments> invalidDependencies() {
3738
return Stream.of(
3839
Arguments.of("X"),
39-
Arguments.of("smithy.foo:bar:1.25.0-SNAPSHOT"),
4040
Arguments.of("smithy.foo:bar:RELEASE"),
4141
Arguments.of("smithy.foo:bar:latest-status"),
4242
Arguments.of("smithy.foo:bar:LATEST"),

0 commit comments

Comments
 (0)