diff --git a/rewrite-gradle/src/test/java/org/openrewrite/gradle/UpgradeDependencyVersionTest.java b/rewrite-gradle/src/test/java/org/openrewrite/gradle/UpgradeDependencyVersionTest.java index 352bdcf2e5e..f8fdae8d369 100644 --- a/rewrite-gradle/src/test/java/org/openrewrite/gradle/UpgradeDependencyVersionTest.java +++ b/rewrite-gradle/src/test/java/org/openrewrite/gradle/UpgradeDependencyVersionTest.java @@ -26,9 +26,9 @@ import org.openrewrite.maven.MavenDownloadingException; import org.openrewrite.maven.MavenExecutionContextView; import org.openrewrite.maven.cache.InMemoryMavenPomCache; +import org.openrewrite.maven.cache.MavenMetadataCacheEntry; import org.openrewrite.maven.cache.MavenPomCache; import org.openrewrite.maven.tree.GroupArtifactVersion; -import org.openrewrite.maven.tree.MavenMetadata; import org.openrewrite.maven.tree.MavenRepository; import org.openrewrite.maven.tree.Pom; import org.openrewrite.maven.tree.ResolvedDependency; @@ -141,13 +141,13 @@ public void putResolvedDependencyPom(ResolvedGroupArtifactVersion dependency, Re } @Override - public @Nullable Optional getMavenMetadata(URI repo, GroupArtifactVersion gav) { + public @Nullable MavenMetadataCacheEntry getMavenMetadata(URI repo, GroupArtifactVersion gav) { assertNotFailureAccess(gav.getGroupId(), gav.getArtifactId()); return delegate.getMavenMetadata(repo, gav); } @Override - public void putMavenMetadata(URI repo, GroupArtifactVersion gav, @Nullable MavenMetadata metadata) { + public void putMavenMetadata(URI repo, GroupArtifactVersion gav, MavenMetadataCacheEntry metadata) { assertNotFailureAccess(gav.getGroupId(), gav.getArtifactId()); delegate.putMavenMetadata(repo, gav, metadata); } diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/CompositeMavenPomCache.java b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/CompositeMavenPomCache.java index fde37b502a1..080519ae450 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/CompositeMavenPomCache.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/CompositeMavenPomCache.java @@ -49,20 +49,22 @@ public void putResolvedDependencyPom(ResolvedGroupArtifactVersion dependency, Re } @Override - public @Nullable Optional getMavenMetadata(URI repo, GroupArtifactVersion gav) { - Optional l1m = l1.getMavenMetadata(repo, gav); + public @Nullable MavenMetadataCacheEntry getMavenMetadata(URI repo, GroupArtifactVersion gav) { + MavenMetadataCacheEntry l1m = l1.getMavenMetadata(repo, gav); if(l1m != null) { return l1m; } - Optional l2m = l2.getMavenMetadata(repo, gav); - if(l2m != null && l2m.isPresent()) { - l1.putMavenMetadata(repo, gav, l2m.get()); + MavenMetadataCacheEntry l2m = l2.getMavenMetadata(repo, gav); + // Promote only a still-fresh hit into l1; an expired l2 entry (validators only) is left for + // the downloader to revalidate, so l1 isn't seeded with a stale value. + if(l2m != null && l2m.getMetadata() != null && !l2m.isExpired()) { + l1.putMavenMetadata(repo, gav, l2m); } return l2m; } @Override - public void putMavenMetadata(URI repo, GroupArtifactVersion gav, MavenMetadata metadata) { + public void putMavenMetadata(URI repo, GroupArtifactVersion gav, MavenMetadataCacheEntry metadata) { l1.putMavenMetadata(repo, gav, metadata); l2.putMavenMetadata(repo, gav, metadata); } diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/InMemoryMavenPomCache.java b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/InMemoryMavenPomCache.java index db5bf28929c..4017828313c 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/InMemoryMavenPomCache.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/InMemoryMavenPomCache.java @@ -34,7 +34,7 @@ public static class MetadataKey { } private final Cache> pomCache; - private final Cache> mavenMetadataCache; + private final Cache mavenMetadataCache; private final Cache> repositoryCache; private final Cache dependencyCache; @@ -61,7 +61,7 @@ public InMemoryMavenPomCache() { public InMemoryMavenPomCache(String cacheNickname, Cache> pomCache, - Cache> mavenMetadataCache, + Cache mavenMetadataCache, Cache> repositoryCache, Cache dependencyCache) { @@ -72,7 +72,7 @@ public InMemoryMavenPomCache(String cacheNickname, } public InMemoryMavenPomCache(Cache> pomCache, - Cache> mavenMetadataCache, + Cache mavenMetadataCache, Cache> repositoryCache, Cache dependencyCache) { this("default", pomCache, mavenMetadataCache, repositoryCache, dependencyCache); @@ -89,13 +89,14 @@ public void putResolvedDependencyPom(ResolvedGroupArtifactVersion dependency, Re } @Override - public @Nullable Optional getMavenMetadata(URI repo, GroupArtifactVersion gav) { + public @Nullable MavenMetadataCacheEntry getMavenMetadata(URI repo, GroupArtifactVersion gav) { + // No freshness policy here: entries are always served as stored (never expired). return mavenMetadataCache.getIfPresent(new MetadataKey(repo, gav)); } @Override - public void putMavenMetadata(URI repo, GroupArtifactVersion gav, @Nullable MavenMetadata metadata) { - mavenMetadataCache.put(new MetadataKey(repo, gav), Optional.ofNullable(metadata)); + public void putMavenMetadata(URI repo, GroupArtifactVersion gav, MavenMetadataCacheEntry metadata) { + mavenMetadataCache.put(new MetadataKey(repo, gav), metadata); } @Override diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/MavenMetadataCacheEntry.java b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/MavenMetadataCacheEntry.java new file mode 100644 index 00000000000..6aeebc2099b --- /dev/null +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/MavenMetadataCacheEntry.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.maven.cache; + +import lombok.Value; +import lombok.With; +import org.jspecify.annotations.Nullable; +import org.openrewrite.maven.tree.MavenMetadata; + +/** + * A cached {@link MavenMetadata} value together with the HTTP validators (ETag / Last-Modified) + * returned by the origin when it was downloaded. This is the single payload a {@link MavenPomCache} + * exchanges for {@code maven-metadata.xml}: it is handed back from + * {@link MavenPomCache#getMavenMetadata} and accepted by {@link MavenPomCache#putMavenMetadata}. + *

+ * Carrying the validators on every read lets the downloader, when an entry's freshness window has + * elapsed ({@link #expired}), revalidate it with a conditional GET ({@code If-None-Match} / + * {@code If-Modified-Since}) rather than re-downloading and re-parsing the body. On a {@code 304 Not + * Modified} the cached {@link #metadata} is reused as-is. + */ +@Value +public class MavenMetadataCacheEntry { + + /** + * The cached metadata value. {@code null} represents a previously cached negative result (a + * download that failed with a client-side error), distinct from a cache miss, which is signalled + * by {@link MavenPomCache#getMavenMetadata} returning {@code null} for the whole entry. + */ + @Nullable + MavenMetadata metadata; + + /** + * The {@code ETag} response header captured when the metadata was downloaded, replayed as + * {@code If-None-Match}. {@code null} if the origin did not provide one. + */ + @Nullable + String etag; + + /** + * The {@code Last-Modified} response header (raw HTTP-date string) captured when the metadata was + * downloaded, replayed verbatim as {@code If-Modified-Since}. {@code null} if the origin did not + * provide one. + */ + @Nullable + String lastModified; + + /** + * The cache's freshness verdict, populated when the entry is read back. {@code true} means the + * entry's freshness window has elapsed and the caller should revalidate it with a conditional GET + * rather than serving {@link #metadata} directly. It is always {@code false} on the write path + * (see {@link #fresh}); caches that expire entries recompute it on read from their own retention + * policy, e.g. via {@link #withExpired(boolean)}. + */ + @With + boolean expired; + + /** + * Create an entry for storing a just-fetched value and its validators. The entry is not + * {@linkplain #expired}; a cache decides expiry when the entry is later read back. + */ + public static MavenMetadataCacheEntry fresh(@Nullable MavenMetadata metadata, @Nullable String etag, @Nullable String lastModified) { + return new MavenMetadataCacheEntry(metadata, etag, lastModified, false); + } +} diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/MavenPomCache.java b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/MavenPomCache.java index bdc389e4d75..e340404beb9 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/MavenPomCache.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/MavenPomCache.java @@ -29,10 +29,24 @@ public interface MavenPomCache { void putResolvedDependencyPom(ResolvedGroupArtifactVersion dependency, ResolvedPom resolved); + /** + * Look up a cached {@code maven-metadata.xml} entry. A {@code null} return is a cache miss. A + * non-null {@link MavenMetadataCacheEntry} carries the value (or a negative result, when its + * {@link MavenMetadataCacheEntry#getMetadata() metadata} is {@code null}) together with any HTTP + * validators; if the entry's freshness window has elapsed the cache marks it + * {@link MavenMetadataCacheEntry#isExpired() expired}, signalling the caller to revalidate it + * with a conditional GET rather than serving it directly. + */ @Nullable - Optional getMavenMetadata(URI repo, GroupArtifactVersion gav); - - void putMavenMetadata(URI repo, GroupArtifactVersion gav, @Nullable MavenMetadata metadata); + MavenMetadataCacheEntry getMavenMetadata(URI repo, GroupArtifactVersion gav); + + /** + * Store a {@code maven-metadata.xml} entry, including any HTTP validators (ETag / Last-Modified) + * the origin returned, so that a later expiry can be revalidated cheaply via a conditional GET. + * The entry is supplied {@linkplain MavenMetadataCacheEntry#fresh fresh}; the cache owns when it + * subsequently considers the entry expired. + */ + void putMavenMetadata(URI repo, GroupArtifactVersion gav, MavenMetadataCacheEntry metadata); @Nullable Optional getPom(ResolvedGroupArtifactVersion gav) throws MavenDownloadingException; diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/RocksdbMavenPomCache.java b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/RocksdbMavenPomCache.java index 158dadb434b..2e911e53619 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/RocksdbMavenPomCache.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/RocksdbMavenPomCache.java @@ -132,13 +132,13 @@ public void putResolvedDependencyPom(ResolvedGroupArtifactVersion dependency, Re } @Override - public @Nullable Optional getMavenMetadata(URI repo, GroupArtifactVersion gav) { + public @Nullable MavenMetadataCacheEntry getMavenMetadata(URI repo, GroupArtifactVersion gav) { //The Maven metadata is not something that should be stored long term, as it will change over time. return null; } @Override - public void putMavenMetadata(URI repo, GroupArtifactVersion gav, @Nullable MavenMetadata metadata) { + public void putMavenMetadata(URI repo, GroupArtifactVersion gav, MavenMetadataCacheEntry metadata) { //The Maven metadata is not something that should be stored long term, as it will change over time. } diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/internal/MavenPomDownloader.java b/rewrite-maven/src/main/java/org/openrewrite/maven/internal/MavenPomDownloader.java index c278d0b1450..a403a1b904a 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/internal/MavenPomDownloader.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/internal/MavenPomDownloader.java @@ -18,6 +18,7 @@ import dev.failsafe.Failsafe; import dev.failsafe.FailsafeException; import dev.failsafe.RetryPolicy; +import dev.failsafe.function.CheckedSupplier; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Timer; @@ -31,6 +32,7 @@ import org.openrewrite.maven.MavenDownloadingException; import org.openrewrite.maven.MavenExecutionContextView; import org.openrewrite.maven.MavenSettings; +import org.openrewrite.maven.cache.MavenMetadataCacheEntry; import org.openrewrite.maven.cache.MavenPomCache; import org.openrewrite.maven.tree.*; import org.openrewrite.semver.Semver; @@ -148,17 +150,25 @@ private MavenPomDownloader(Map projectPoms, HttpSender httpSender, Ex } byte[] sendRequest(HttpSender.Request request) throws IOException, HttpSenderResponseException { + return withRetryAndTiming(() -> { + try (HttpSender.Response response = httpSender.send(request)) { + if (!response.isSuccessful()) { + throw new HttpSenderResponseException(null, response.getCode(), + new String(response.getBodyAsBytes())); + } + return response.getBodyAsBytes(); + } + }); + } + + /** + * Run an HTTP exchange under the shared retry policy, unwrapping the failsafe/unchecked wrappers + * back into the checked exceptions callers handle, and recording the elapsed resolution time. + */ + private T withRetryAndTiming(CheckedSupplier exchange) throws IOException, HttpSenderResponseException { long start = System.nanoTime(); try { - return Failsafe.with(retryPolicy).get(() -> { - try (HttpSender.Response response = httpSender.send(request)) { - if (!response.isSuccessful()) { - throw new HttpSenderResponseException(null, response.getCode(), - new String(response.getBodyAsBytes())); - } - return response.getBodyAsBytes(); - } - }); + return Failsafe.with(retryPolicy).get(exchange); } catch (FailsafeException failsafeException) { if (failsafeException.getCause() instanceof HttpSenderResponseException) { throw (HttpSenderResponseException) failsafeException.getCause(); @@ -171,6 +181,81 @@ byte[] sendRequest(HttpSender.Request request) throws IOException, HttpSenderRes } } + /** + * Like {@link #requestAsAuthenticatedOrAnonymous} but tailored to metadata: it optionally adds + * conditional headers and, unlike {@link #sendRequest}, surfaces a {@code 304 Not Modified} as a + * non-error {@link MetadataDownload} (empty body) rather than throwing. Other non-2xx responses + * still raise {@link HttpSenderResponseException}, preserving the existing error handling. + */ + private MetadataDownload requestMetadata(MavenRepository repo, String uriString, + @Nullable MavenMetadataCacheEntry revalidation) throws HttpSenderResponseException, IOException { + try { + HttpSender.Request.Builder request = applyConditionalHeaders(httpSender.get(uriString), revalidation); + return sendMetadataRequest(applyAuthenticationAndTimeoutToRequest(repo, request).build()); + } catch (HttpSenderResponseException e) { + if (hasCredentials(repo) && e.isClientSideException()) { + try { + HttpSender.Request.Builder request = applyConditionalHeaders(httpSender.get(uriString), revalidation); + return sendMetadataRequest(request.build()); + } catch (HttpSenderResponseException retryException) { + throw retryException.isAccessDenied() ? e : retryException; + } + } + throw e; + } + } + + private static HttpSender.Request.Builder applyConditionalHeaders(HttpSender.Request.Builder request, + @Nullable MavenMetadataCacheEntry revalidation) { + if (revalidation != null) { + if (revalidation.getEtag() != null) { + request.withHeader("If-None-Match", revalidation.getEtag()); + } + if (revalidation.getLastModified() != null) { + request.withHeader("If-Modified-Since", revalidation.getLastModified()); + } + } + return request; + } + + MetadataDownload sendMetadataRequest(HttpSender.Request request) throws IOException, HttpSenderResponseException { + return withRetryAndTiming(() -> { + try (HttpSender.Response response = httpSender.send(request)) { + int code = response.getCode(); + if (code == 304) { + // Not Modified: there is no body to read; the caller reuses the cached value. + return new MetadataDownload(code, null, header(response, "ETag"), header(response, "Last-Modified")); + } + if (!response.isSuccessful()) { + throw new HttpSenderResponseException(null, code, new String(response.getBodyAsBytes())); + } + return new MetadataDownload(code, response.getBodyAsBytes(), header(response, "ETag"), header(response, "Last-Modified")); + } + }); + } + + private static @Nullable String header(HttpSender.Response response, String name) { + for (Map.Entry> entry : response.getHeaders().entrySet()) { + if (name.equalsIgnoreCase(entry.getKey())) { + List values = entry.getValue(); + return values == null || values.isEmpty() ? null : values.get(0); + } + } + return null; + } + + @Value + static class MetadataDownload { + int code; + byte @Nullable [] body; + + @Nullable + String etag; + + @Nullable + String lastModified; + } + private Map projectPomsByGav(Map projectPoms) { Map result = new HashMap<>(); for (Pom projectPom : projectPoms.values()) { @@ -267,12 +352,23 @@ public MavenMetadata downloadMetadata(GroupArtifactVersion gav, @Nullable Resolv continue; } attemptedUris.add(repo.getUri()); - Optional result = mavenCache.getMavenMetadata(URI.create(repo.getUri()), gav); + URI repoUri = URI.create(repo.getUri()); + MavenMetadataCacheEntry cached = mavenCache.getMavenMetadata(repoUri, gav); + // A fresh cache hit (value or negative) is served directly; a miss or an expired entry + // leaves result null so the download path below runs (revalidating when validators exist). + Optional result = cached == null || cached.isExpired() ? null : Optional.ofNullable(cached.getMetadata()); + // Validators captured from this iteration's download/304, persisted alongside the metadata so + // a later expiry can be revalidated with a conditional GET rather than a full re-download. + String fetchedEtag = null; + String fetchedLastModified = null; + // Whether this iteration produced the value (download, file read, derivation, or 304), so we + // only (re)cache freshly obtained values and leave an untouched cache hit alone. + boolean produced = false; if (result == null) { // Not in the cache, attempt to download it. boolean cacheEmptyResult = false; try { - String scheme = URI.create(repo.getUri()).getScheme(); + String scheme = repoUri.getScheme(); String baseUri = repo.getUri() + (repo.getUri().endsWith("/") ? "" : "/") + requireNonNull(gav.getGroupId()).replace('.', '/') + '/' + gav.getArtifactId() + '/' + @@ -285,13 +381,33 @@ public MavenMetadata downloadMetadata(GroupArtifactVersion gav, @Nullable Resolv MavenMetadata parsed = MavenMetadata.parse(Files.readAllBytes(path)); if (parsed != null) { result = Optional.of(parsed); + produced = true; } } } else { - byte[] responseBody = requestAsAuthenticatedOrAnonymous(repo, baseUri + "maven-metadata.xml"); - MavenMetadata parsed = MavenMetadata.parse(responseBody); - if (parsed != null) { - result = Optional.of(parsed); + // If an expired entry with a validator was retained, revalidate it with a + // conditional GET so an unchanged metadata can be confirmed by a cheap 304 + // instead of re-downloading and re-parsing the body. + MavenMetadataCacheEntry revalidation = cached != null && cached.isExpired() ? cached : null; + MetadataDownload download = requestMetadata(repo, baseUri + "maven-metadata.xml", revalidation); + if (download.getCode() == 304 && revalidation != null && revalidation.getMetadata() != null) { + // Not Modified: reuse the cached value, skipping the parse entirely. + result = Optional.of(revalidation.getMetadata()); + fetchedEtag = download.getEtag() != null ? download.getEtag() : revalidation.getEtag(); + fetchedLastModified = download.getLastModified() != null ? download.getLastModified() : revalidation.getLastModified(); + produced = true; + Counter.builder("rewrite.maven.metadata.revalidated") + .tag("repositoryUri", repo.getUri()) + .register(Metrics.globalRegistry) + .increment(); + } else { + MavenMetadata parsed = MavenMetadata.parse(download.getBody()); + if (parsed != null) { + result = Optional.of(parsed); + fetchedEtag = download.getEtag(); + fetchedLastModified = download.getLastModified(); + produced = true; + } } } } catch (HttpSenderResponseException e) { @@ -316,6 +432,7 @@ public MavenMetadata downloadMetadata(GroupArtifactVersion gav, @Nullable Resolv .register(Metrics.globalRegistry) .increment(); result = Optional.of(derivedMeta); + produced = true; } } catch (HttpSenderResponseException | MavenDownloadingException | IOException e) { repositoryResponses.put(repo, e.getMessage()); @@ -324,7 +441,7 @@ public MavenMetadata downloadMetadata(GroupArtifactVersion gav, @Nullable Resolv if (result == null && cacheEmptyResult) { // If there was no fatal failure while attempting to find metadata and there was no metadata retrieved // from the current repo, cache an empty result. - mavenCache.putMavenMetadata(URI.create(repo.getUri()), gav, null); + mavenCache.putMavenMetadata(repoUri, gav, MavenMetadataCacheEntry.fresh(null, null, null)); } } else if (!result.isPresent()) { repositoryResponses.put(repo, "Did not attempt to download because of a previous failure to retrieve from this repository."); @@ -337,7 +454,11 @@ public MavenMetadata downloadMetadata(GroupArtifactVersion gav, @Nullable Resolv } else { mavenMetadata = mergeMetadata(mavenMetadata, result.get()); } - mavenCache.putMavenMetadata(URI.create(repo.getUri()), gav, result.get()); + // Only (re)cache the value this iteration produced; a value served straight from the + // cache is left untouched so its stored validators aren't overwritten with nulls. + if (produced) { + mavenCache.putMavenMetadata(repoUri, gav, MavenMetadataCacheEntry.fresh(result.get(), fetchedEtag, fetchedLastModified)); + } } } diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/cache/CompositeMavenPomCacheTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/cache/CompositeMavenPomCacheTest.java new file mode 100644 index 00000000000..ee7e291746b --- /dev/null +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/cache/CompositeMavenPomCacheTest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.maven.cache; + +import org.junit.jupiter.api.Test; +import org.openrewrite.maven.tree.GroupArtifactVersion; +import org.openrewrite.maven.tree.MavenMetadata; + +import java.net.URI; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; + +class CompositeMavenPomCacheTest { + + private static final URI REPO = URI.create("https://repo.example/maven2"); + private static final GroupArtifactVersion GAV = new GroupArtifactVersion("com.example", "example", "1.0"); + + private static MavenMetadata metadata() { + return new MavenMetadata(new MavenMetadata.Versioning(emptyList(), emptyList(), null, null, null, null)); + } + + @Test + void forwardsValidatorsToL2OnPut() { + InMemoryMavenPomCache l2 = new InMemoryMavenPomCache(); + CompositeMavenPomCache cache = new CompositeMavenPomCache(new InMemoryMavenPomCache(), l2); + + MavenMetadata metadata = metadata(); + cache.putMavenMetadata(REPO, GAV, MavenMetadataCacheEntry.fresh(metadata, "\"etag-1\"", "Wed, 21 Oct 2015 07:28:00 GMT")); + + // The persistent layer retains the value and its validators, not just the bare metadata. + MavenMetadataCacheEntry l2Entry = l2.getMavenMetadata(REPO, GAV); + assertThat(l2Entry).isNotNull(); + assertThat(l2Entry.getMetadata()).isSameAs(metadata); + assertThat(l2Entry.getEtag()).isEqualTo("\"etag-1\""); + assertThat(l2Entry.getLastModified()).isEqualTo("Wed, 21 Oct 2015 07:28:00 GMT"); + } + + @Test + void returnsL2EntryWithValidatorsWhenL1Misses() { + InMemoryMavenPomCache l1 = new InMemoryMavenPomCache(); + InMemoryMavenPomCache l2 = new InMemoryMavenPomCache(); + l2.putMavenMetadata(REPO, GAV, MavenMetadataCacheEntry.fresh(metadata(), "\"etag-1\"", "Wed, 21 Oct 2015 07:28:00 GMT")); + CompositeMavenPomCache cache = new CompositeMavenPomCache(l1, l2); + + MavenMetadataCacheEntry entry = cache.getMavenMetadata(REPO, GAV); + assertThat(entry).isNotNull(); + assertThat(entry.getEtag()).isEqualTo("\"etag-1\""); + assertThat(entry.getLastModified()).isEqualTo("Wed, 21 Oct 2015 07:28:00 GMT"); + + // A fresh l2 hit is promoted into l1 so the validators are available without re-consulting l2. + MavenMetadataCacheEntry promoted = l1.getMavenMetadata(REPO, GAV); + assertThat(promoted).isNotNull(); + assertThat(promoted.getEtag()).isEqualTo("\"etag-1\""); + } +} diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/internal/MavenPomDownloaderTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/internal/MavenPomDownloaderTest.java index f2dfbf98c46..e8fa01a678c 100755 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/internal/MavenPomDownloaderTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/internal/MavenPomDownloaderTest.java @@ -36,6 +36,9 @@ import org.openrewrite.maven.MavenExecutionContextView; import org.openrewrite.maven.MavenParser; import org.openrewrite.maven.MavenSettings; +import org.openrewrite.maven.cache.InMemoryMavenPomCache; +import org.openrewrite.maven.cache.MavenMetadataCacheEntry; +import org.openrewrite.maven.cache.MavenPomCache; import org.openrewrite.maven.http.OkHttpSender; import org.openrewrite.maven.tree.*; import org.openrewrite.test.RewriteTest; @@ -53,6 +56,7 @@ import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.StreamSupport; import static java.util.Collections.*; @@ -1781,4 +1785,187 @@ public MockResponse dispatch(RecordedRequest request) { assertDoesNotThrow(() -> downloader.download(new GroupArtifactVersion(existing.getGroupId(), existing.getArtifactId(), "5.5.0"), null, null, repositories)); } } + + @Nested + class ConditionalMetadataRequests { + private final ExecutionContext ctx = HttpSenderExecutionContextView.view(new InMemoryExecutionContext()) + .setHttpSender(new HttpUrlConnectionSender(Duration.ofSeconds(5), Duration.ofSeconds(5))); + + @Language("xml") + private static final String METADATA_V1 = """ + + org.openrewrite.test + foo + + 1.0 + 1.0 + + 1.0 + + + + """; + + @Language("xml") + private static final String METADATA_V2 = """ + + org.openrewrite.test + foo + + 2.0 + 2.0 + + 1.0 + 2.0 + + + + """; + + private static final GroupArtifact GA = new GroupArtifact("org.openrewrite.test", "foo"); + + private MavenPomDownloader downloader(MavenPomCache cache) { + ExecutionContext mavenCtx = MavenExecutionContextView.view(ctx) + .setAddCentralRepository(false) + .setAddLocalRepository(false) + .setPomCache(cache); + return new MavenPomDownloader(emptyMap(), mavenCtx); + } + + private static MavenRepository repo(MockWebServer server) { + return MavenRepository.builder().id("test").uri(server.url("/").toString()).build(); + } + + /** + * A dispatcher that records every {@code maven-metadata.xml} request into {@code sink} and + * delegates its response to {@code onMetadata}, while answering repository-normalization probes + * (OPTIONS/HEAD against the base URL) with a plain 200. + */ + private static Dispatcher metadataDispatcher(List sink, Function onMetadata) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String path = request.getPath(); + if (path != null && path.endsWith("maven-metadata.xml")) { + sink.add(request); + return onMetadata.apply(request); + } + return new MockResponse().setResponseCode(200); + } + }; + } + + @Test + void reusesCachedValueOn304() throws Exception { + String etag = "\"v1\""; + List metadataRequests = new ArrayList<>(); + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(metadataDispatcher(metadataRequests, request -> + etag.equals(request.getHeader("If-None-Match")) ? + new MockResponse().setResponseCode(304) : + new MockResponse().setResponseCode(200).setHeader("ETag", etag).setBody(METADATA_V1))); + server.start(); + MavenPomDownloader downloader = downloader(new RetainingPomCache()); + MavenRepository repo = repo(server); + + // Cold: unconditional GET -> 200, caches value + ETag validator. + MavenMetadata first = downloader.downloadMetadata(GA, null, List.of(repo)); + assertThat(first.getVersioning().getVersions()).containsExactly("1.0"); + + // Warm-but-expired: getMavenMetadata misses, but the retained validator drives a + // conditional GET -> 304 -> the cached value is reused without re-parsing a body. + MavenMetadata second = downloader.downloadMetadata(GA, null, List.of(repo)); + assertThat(second.getVersioning().getVersions()).containsExactly("1.0"); + + assertThat(metadataRequests).hasSize(2); + assertThat(metadataRequests.get(0).getHeader("If-None-Match")).isNull(); + assertThat(metadataRequests.get(1).getHeader("If-None-Match")).isEqualTo(etag); + } + } + + @Test + void downloadsNewMetadataWhenChanged() throws Exception { + // The origin's current validator and body; flipping these simulates a newly published version. + String[] currentEtag = {"\"v1\""}; + String[] currentBody = {METADATA_V1}; + List metadataRequests = new ArrayList<>(); + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(metadataDispatcher(metadataRequests, request -> + currentEtag[0].equals(request.getHeader("If-None-Match")) ? + new MockResponse().setResponseCode(304) : + new MockResponse().setResponseCode(200).setHeader("ETag", currentEtag[0]).setBody(currentBody[0]))); + server.start(); + MavenPomDownloader downloader = downloader(new RetainingPomCache()); + MavenRepository repo = repo(server); + + // Cold download caches v1 + its ETag. + assertThat(downloader.downloadMetadata(GA, null, List.of(repo)).getVersioning().getVersions()).containsExactly("1.0"); + + // A new version is published: the origin's validator no longer matches, so the conditional + // GET (If-None-Match: v1) is answered with a fresh 200 carrying the new value and validator. + currentEtag[0] = "\"v2\""; + currentBody[0] = METADATA_V2; + assertThat(downloader.downloadMetadata(GA, null, List.of(repo)).getVersioning().getVersions()).containsExactly("1.0", "2.0"); + + // The new validator (v2) was stored, so a subsequent revalidation is a 304 reusing v2. + assertThat(downloader.downloadMetadata(GA, null, List.of(repo)).getVersioning().getVersions()).containsExactly("1.0", "2.0"); + + assertThat(metadataRequests).hasSize(3); + assertThat(metadataRequests.get(0).getHeader("If-None-Match")).isNull(); + assertThat(metadataRequests.get(1).getHeader("If-None-Match")).isEqualTo("\"v1\""); + assertThat(metadataRequests.get(2).getHeader("If-None-Match")).isEqualTo("\"v2\""); + } + } + + @Test + void sendsNoConditionalHeadersWhenCacheRetainsNoValidator() throws Exception { + List metadataRequests = new ArrayList<>(); + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(metadataDispatcher(metadataRequests, request -> + new MockResponse().setResponseCode(200).setHeader("ETag", "\"v1\"").setBody(METADATA_V1))); + server.start(); + + // A cache that always misses and never retains a validator, so every lookup falls back + // to a plain, unconditional download. + MavenPomCache nonRevalidating = new InMemoryMavenPomCache() { + @Override + public @Nullable MavenMetadataCacheEntry getMavenMetadata(URI repo, GroupArtifactVersion gav) { + return null; + } + }; + MavenPomDownloader downloader = downloader(nonRevalidating); + MavenRepository repo = repo(server); + + downloader.downloadMetadata(GA, null, List.of(repo)); + downloader.downloadMetadata(GA, null, List.of(repo)); + + assertThat(metadataRequests).hasSize(2); + assertThat(metadataRequests).allSatisfy(r -> assertThat(r.getHeader("If-None-Match")).isNull()); + } + } + + /** + * A cache that always reports its retained entries as {@linkplain MavenMetadataCacheEntry#isExpired() + * expired} (simulating an elapsed freshness window), so a cached value is never served directly + * but its validators still drive a conditional GET. + */ + static class RetainingPomCache extends InMemoryMavenPomCache { + private final Map retained = new HashMap<>(); + + private static String key(URI repo, GroupArtifactVersion gav) { + return repo + "|" + gav; + } + + @Override + public @Nullable MavenMetadataCacheEntry getMavenMetadata(URI repo, GroupArtifactVersion gav) { + MavenMetadataCacheEntry entry = retained.get(key(repo, gav)); + return entry == null ? null : entry.withExpired(true); + } + + @Override + public void putMavenMetadata(URI repo, GroupArtifactVersion gav, MavenMetadataCacheEntry metadata) { + retained.put(key(repo, gav), metadata); + } + } + } }