Skip to content

Conditional GET (ETag / Last-Modified) for Maven metadata downloads - #8081

Open
knutwannheden wants to merge 2 commits into
mainfrom
conditional-gets-for-maven-metadata
Open

Conditional GET (ETag / Last-Modified) for Maven metadata downloads#8081
knutwannheden wants to merge 2 commits into
mainfrom
conditional-gets-for-maven-metadata

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Motivation

Caches that store maven-metadata.xml version lists with a freshness window (for example a persistent cross-JVM cache with a 1-hour TTL) pay a recurring "cold metadata" tax: once the window elapses, the next resolution re-downloads and re-parses the metadata for every GAV across every configured repository, even when nothing has changed. Released POMs are immutable and cache cleanly, so the cost is specifically the version-list metadata that drives latest.release, version ranges, RELEASE / LATEST, BOM / plugin version discovery, and snapshot timestamps. The TTL can't simply be lengthened because metadata is genuinely mutable — new versions get published, and latest.release-style recipes would silently miss them. The correct fix is an HTTP conditional request: send If-None-Match (ETag) / If-Modified-Since (Last-Modified) and handle 304 Not Modified, whose empty body lets us skip both the download and the parse while refreshing the entry. Maven Central, Artifactory, and Nexus all support these on maven-metadata.xml.

Examples

MavenPomCache exchanges a single MavenMetadataCacheEntry for maven-metadata.xml — it carries the value, its ETag / Last-Modified validators, and an expired verdict. A cache that wants conditional GETs persists the validators and, once an entry's freshness window has elapsed, hands it back marked expired so the downloader revalidates it instead of re-downloading:

class MyCache implements MavenPomCache {
    // Persist the value together with the ETag / Last-Modified the origin returned.
    @Override
    public void putMavenMetadata(URI repo, GroupArtifactVersion gav, MavenMetadataCacheEntry metadata) {
        store(repo, gav, metadata, /* freshAt */ now());
    }

    // Serve a fresh entry directly; once it has expired, hand it back marked expired so the
    // downloader revalidates it with a conditional GET rather than re-downloading and re-parsing.
    @Override
    public @Nullable MavenMetadataCacheEntry getMavenMetadata(URI repo, GroupArtifactVersion gav) {
        Entry e = lookup(repo, gav);
        return e == null ? null : e.value.withExpired(isStale(e.freshAt));
    }
}

On the next resolution after the entry expires, the downloader sends If-None-Match / If-Modified-Since; a 304 reuses the cached value (no parse) and re-stores it (refreshing freshness), while a 200 replaces the value and captures the new validators. A cache that never marks an entry expired (the in-memory default) keeps serving it directly with no network call.

Summary

  • Added MavenMetadataCacheEntry — a value type carrying a cached MavenMetadata, its ETag / Last-Modified validators, and an expired read-time verdict. Write-side entries are built with MavenMetadataCacheEntry.fresh(...) (never expired); caches that expire entries recompute the verdict on read (e.g. via withExpired(...)).
  • Reworked MavenPomCache's metadata methods into a single get/put pair carrying that entry: getMavenMetadata returns a @Nullable MavenMetadataCacheEntry (a null return is a cache miss; a non-null entry carries the value — or a negative result when its metadata is null — together with any validators, and is marked expired when the caller should revalidate), and putMavenMetadata accepts the same entry. This replaces the previous Optional<MavenMetadata>-returning get and the bare put.
  • Breaking change: the two methods are no longer default and their signatures changed, so every MavenPomCache implementation must adopt the new entry type. Updated in this PR: InMemoryMavenPomCache, RocksdbMavenPomCache (metadata remains a no-op there), and CompositeMavenPomCache.
  • MavenPomDownloader.downloadMetadata consults the single get, serves a fresh hit (value or negative) directly with no network call, and on a miss or expired entry issues a conditional GET (If-None-Match / If-Modified-Since) using the entry's validators; a 304 reuses the cached value (skipping the parse), a 200 captures the new ETag / Last-Modified. The 304 status is surfaced through the HttpSender response rather than being treated as an error.
  • The downloader writes the metadata cache only when it actually produced the value this iteration (download, file read, derivation, or 304 revalidation) rather than re-writing a value served straight from the cache, so a stored validator is never overwritten with nulls on a cache hit.
  • CompositeMavenPomCache needs no conditional-GET-specific code: forwarding the unified get/put through both layers carries the validators automatically. It promotes only a still-fresh l2 hit into l1, leaving an expired l2 entry (validators only) for the downloader to revalidate rather than seeding l1 with a stale value.

Test plan

  • MavenPomDownloaderTest.ConditionalMetadataRequests.reusesCachedValueOn304 — the second request sends If-None-Match, the server replies 304, and the cached value is reused without re-parsing a body.
  • downloadsNewMetadataWhenChanged — a conditional GET whose validator no longer matches receives a fresh 200 (new version surfaces), the new ETag is stored, and the following revalidation is a 304 against the new ETag.
  • sendsNoConditionalHeadersWhenCacheRetainsNoValidator — a cache that always misses performs only plain, unconditional downloads.
  • CompositeMavenPomCacheTest — the unified put forwards a value and its validators to the persistent layer, and an l2 entry (with validators) surfaces through the composite on an l1 miss.
  • Full :rewrite-maven:test suite plus :rewrite-gradle consumers, and license checks, pass.

Re-validating an expired `maven-metadata.xml` entry previously meant a full
re-download and re-parse for every GAV across every configured repository. This
adds support for HTTP conditional requests so an unchanged version list can be
confirmed by a cheap `304 Not Modified` instead.

`MavenPomCache` gains two non-breaking `default` methods: one to hand back a
possibly-stale entry together with its `ETag` / `Last-Modified` validators, and a
validator-aware `putMavenMetadata` overload. Existing cache implementations
compile and behave exactly as before (no validator -> unconditional download).

`MavenPomDownloader.downloadMetadata` now issues an `If-None-Match` /
`If-Modified-Since` request when a validator is available, reuses the cached value
(skipping the parse) on a 304, and stores the response's validators on a 200.
@knutwannheden
knutwannheden force-pushed the conditional-gets-for-maven-metadata branch from b5db869 to 937a7e0 Compare June 19, 2026 12:47
* @param etag the {@code ETag} response header, or {@code null}
* @param lastModified the {@code Last-Modified} response header, or {@code null}
*/
default void putMavenMetadata(URI repo, GroupArtifactVersion gav, @Nullable MavenMetadata metadata,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Like to see if we can do this without adding more overloads.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've simplified the APIs now. Please check if that works for you.

PR #8081 added conditional-GET support by bolting a parallel pair of methods
onto MavenPomCache: getMavenMetadataForRevalidation (consulted after a miss to
recover a stale value + its validators) and a 4-arg putMavenMetadata overload
carrying ETag/Last-Modified. That left two ways to get and two ways to put, and
— because the validators rode on separate methods — every cache decorator had
to remember to forward them or silently drop the feature (CompositeMavenPomCache
did exactly that).

Collapse the four methods into the existing two. getMavenMetadata now returns a
MavenMetadataCacheEntry (renamed from MavenMetadataValidation) carrying the
value, its validators, and an `expired` verdict; putMavenMetadata accepts the
same entry. A single get conveys all three outcomes the downloader needs:

  null            -> miss, unconditional download
  entry !expired  -> fresh hit (value or negative), served directly, no network
  entry expired   -> revalidate via conditional GET using the entry's validators

The `expired` flag is a read-time verdict only (always false on the write path,
via MavenMetadataCacheEntry.fresh); caches that expire entries recompute it on
read. CompositeMavenPomCache no longer needs any conditional-GET-specific
override — plain forwarding of get/put carries the validators through both
layers, so the decorator-drops-the-feature hazard is structurally gone.

Breaking change to MavenPomCache (no default methods); InMemory, Rocksdb,
Composite, the downloader, and the test fakes are updated accordingly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants