feat(maven): the winner-path virtual read (S30 plan: 17/25)
Summary
The kind=1 dispatch slot's read arm for version-level primary files and version-level maven-metadata.xml: resolve through the Step 11b facade, serve by winner kind, fall back to the cache on a pre-header drain failure, answer conditionals from the row's ETag, and book ADR-012's two virtual records with the delivered pull virtual-attributed exactly once.
-
Serve by winner kind. A hosted winner serves through the extracted shared shape (the access bump and the conditional travel with it) on a synthesized winner
Resolution; a remote winner serves its response contract over the fetch result; the cache fallback serves exactly as a winner's response with the supplying position's row-derivedETagand checksum headers, advertised only where the batched verdict names the bytes that serve. No upstream response header is blanket-copied. -
A metadata winner's cold fill is a fetch and a drain, and the drain is part of the fetch. The fill completes before the first header, carrying the full header set —
ETag, SHA-1, and MD5 under FIPS-off — and every drain failure re-enters the resolver's drain-fallback entry point (#1434 (closed)): the winner's own cached copy served,503 upstream_unavailablewhere no cache entry stands, the coalescing503withRetry-After: 1, the staging-refusal row's jittered503, abandonment unanswered, and a local failure mapped through the usual500. An artifact-classHEADcold fill drains before it writes anything either, so its drain failure takes the same fallback. -
Exactly one, virtual-attributed pull event. The synthesized winner resolution's
Virtualmark suppresses the inner hosted emission; the delivered pull booksrepository_kindvirtual, the virtual repository's id, and thecache_hitdimension.artifact_registry_virtual_cache_missbooks when the serve needs the winner's own bytes — the hosted origin or a cold remote fill — withupstream_typenaming the winner's kind, so a warm copy's hit books the pull alone. -
A remote winner's warm serve moves the shared
downloads_countunder the delivery gate, and the same delivered arms bump the remote rows'last_downloaded_at. -
The package-level metadata and sidecar classes keep
writeNotImplementeduntil Step 18, pinned by a routing test so the single-handler swap cannot silently leave them unarmed. -
The plumbing the read consumes lands in this diff.
VirtualPositioncarries the rows the resolution already paid for — the batched hostedmaven_filesrow, themaven_repositories.idthe verdict matched on, and the remote position's lookup verdict (virtual_assembly.go) — and the facade keeps thevirtual.Requestit resolved on,resolveDrainFailurecalling the resolver's drain-fallback entry point (#1434 (closed), merged) that partitions a failure before any header (virtual_operations.go).
Scope
Layer 2 of 2 of Step 17 of the S30 plan (### Step 17), stacked on !3002 (merged) until that layer merged; this MR now targets main.
This MR also drops the three not yet emitted marks in docs/dev/usage-data.md (the intro line, the cache_hit cell, the virtual_cache_miss row) — the catalog rows ship ahead of their emitter, and the qualifier must not outlive it.
It also moves the miss booking to the serve outcome, per the semantics call the ADR-012/S23 owner made on the layer's review thread 10e9a52c: a hosted winner and a cold remote fill book the miss; a warm copy's hit books the pull alone with cache_hit=true (npm/OCI's one-verdict-per-read pattern, remote_cache_event.go) — not the every-winner-read booking at the verdict.
This MR narrows docs/testing/e2e/maven.md's out-of-scope list for the two classes it arms and adds no scenario of its own: the end-to-end scenarios for the virtual read are Step 20's, the step that mounts the harness the catalog's virtual rows need.
Depends on
main— !3002 (merged) is merged (the squash888d50176under merge commit5fe1e4db3), so this MR targetsmainand the diff is the handler and its suites only.- Merged
main: the resolver's drain-fallback entry point (#1434 (closed)) is onmain.
Plan and spec amendments
Per docs/dev/conventions.md (### Review-driven plan and spec corrections), this MR carries the corrections its own review proved:
- Step 17's Scope gains three clauses the implementation proved: the artifact-class
HEADcold fill drains before it writes anything (its drain failure reaches the entry point rather than mapping straight to503); the remote winner's warm serve movesdownloads_countunder the delivery gate; and the same delivered arms bump the remote rows'last_downloaded_at, which S14 records on a delivered hit and fill (7a9d1d0e2). The clause that accepted the last gap is reverted by061dbffd2. - Step 17's Files entry moves the miss to the serve outcome, per !3002 (merged)'s
149012fad: a hosted winner and a cold remote fill book it, a warm copy's hit books no miss, and the pull'scache_hitrecords the other half of that verdict. The plan clause carrying the verdict rule is corrected bye8fcaded8. - Step 17's Scope and Files entry carry the review's other corrections: the artifact fill's delivery verdict splits from its commit signal, so an interrupted copy books nothing and a commit failure past the last byte books the pull alone (
e3f0161ee); the fill headers stage past theHEADdrain and the metadata fill evaluates the registry's validator (b17bf5327); a vanished blob answers the remote winner's503rather than the hosted arm's404(0a303bc6a). - Step 17's Files entry bounds the batched digest columns to the blob a serve delivers. The entry read as though the columns accompany any warm or fallback serve, but a resolution that re-reads the row (a revalidation, a fallback past a replacement) can serve a newer blob than the batch, which paired this blob's
ETagand length with another blob'sX-Checksum-Sha1— the digest Maven Resolver trusts over the.sha1sidecar. The projection now advertises the columns only when the batch names the delivered blob, and omits them otherwise (bc7e8491, 3ee8556d).
Size justification
~890 reviewable source lines, measured at 2b702e586 with git diff --numstat 0f1b841df...2b702e586: +4,461 −109 total, split Go source +893 (tests +3,478, docs and config +90) — past the 500 reviewable-LOC guide. Splitting the handler from its suites would break the tests-ship-with-the-code rule, and the plumbing already landed separately in !3002 (merged); what remains is one handler and the suites that pin it. Split by file group: virtual_read.go 718, handler.go + usagedata.go + virtual_assembly/operations ~175, test suites ~3,478, docs/config ~90.
Spec coverage
Spec: docs/specs/S30-maven-virtual.md (### Maven virtual resolution, ### Resolution, ### Conditional requests, ### Error Cases, ### Wide events). Plan: docs/plans/2026-09-10-s30-maven-virtual.md, ### Step 17.
Acceptance criteria
| Criterion | Test |
|---|---|
| Single winner / hosted-and-remote mix / priority over a fresh hosted hit / warm short-circuit | TestVirtualRead_HostedAndRemoteMixServesTheHostedWinner, TestVirtualRead_WarmRemoteWinnerShortCircuits |
| Version-level metadata resolves single-winner | TestVirtualRead_VersionLevelMetadataServesTheWinnerVerbatim |
No winner / no upstreams / all-soft-deleted 404; all-unhealthy 503; hosted miss beside unhealthy remote 404; outage not an absence |
TestVirtualRead_NoWinnerAnswers404, _NoUpstreamsAnswers404, _AllEligibleRemotesUnhealthyAnswers503, _HostedMissBesideAnUnhealthyRemoteAnswers404, TestVirtualReadIntegration_OutageWithNoCachedCopyAnswers503 |
| Cache fallback serves a non-winning position; rule-excluded barred; winner Fetch failure does not fall through; transport failure falls to the cache | TestVirtualReadIntegration_CacheFallbackServesTheHighestPriorityCopy, _RuleExcludedPositionIsBarredFromTheFallback, TestVirtualRead_WinnerFetchRefusalRelaysTheUpstreamStatus, _WinnerTransportFailureServesTheWinnersOwnCopy |
Drain failure before the first header: cached copy or 503 (metadata and artifact HEAD pairs, no byte written before the fallback) |
TestVirtualReadIntegration_MetadataDrainFailureServesTheWinnersCachedCopy, ...WithoutACachedCopyAnswers503, _HeadArtifactDrainFailure... pair |
| Cold-fill header subsets; metadata fill's full header set (ETag, SHA-1, MD5 FIPS-off) | TestVirtualRead_ColdRemoteFillStreamsTheHeaderSubset, TestVirtualReadIntegration_MetadataColdFillCommitsBeforeTheFirstHeader |
| Hosted full header contract; access bump; winner-path conditionals; HEAD/GET parity | TestVirtualRead_HostedWinnerCarriesS10sFullHeaderContract, _HostedWinnerRecordsTheWinnersAccessSignal, _HostedWinnerConditionalRequest, _WarmRemoteWinnerConditionalRequest, _HeadMatchesGet |
snapshot_metadata_always_revalidate reaches the per-position store |
TestMavenVirtualRemoteBinding_CarriesTheRevalidateFlag + lookup integration subtest |
| Data-error veto pair; soft-delete halves | TestVirtualRead_DataErrorWithholdsTheNotFound, TestVirtualReadIntegration_SoftDeletedUpstreamCannotSupplyTheFallback, _AllSoftDeletedAnswersTheNoUpstreams404 |
Exactly one virtual-attributed pull with cache_hit; miss event with upstream_type; entry-point answers at the handler (local 500, coalescing 503, staging-refusal 503 jittered, departed caller 499) |
TestVirtualRead_HostedWinnerEmitsOneVirtualAttributedPull, TestVirtualReadIntegration_HeadColdFillBooksTheMissAlone, TestServeVirtualRead_StagingRefusalAnswersTheShed503, TestServeVirtualRead_DepartedCallerIsStamped499 |
Unarmed classes keep the 501 |
TestVirtualRead_UnarmedClassesKeepTheInterim501, TestBuildMavenDispatcher_AnswersTheInterim501OnTheUnarmedClasses |
Error cases (this step's rows of the spec's Error Cases table)
| Row | Test |
|---|---|
Winner's Fetch fails after a successful Probe: status relayed, out-of-class substituted by 503 + health interval |
TestVirtualRead_WinnerFetchRefusalRelaysTheUpstreamStatus, TestMavenVirtualReadErrors_WinnerFetchFailedRelaysTheUpstreamStatus |
Transport failure before the first header: winner's own copy or 503 (metadata and artifact HEAD pairs) |
TestVirtualReadIntegration_MetadataDrainFailure..., _HeadArtifactDrainFailure... pairs |
| Transport failure after the first header: body cut short, no fallback | inherited from the shared copy machinery's suites (judgeCopy/copyBody, download_test.go) — the virtual arm adds no new code path here |
Abandonment: no envelope, 499 stamped, never 404 |
TestServeVirtualRead_DepartedCallerIsStamped499 |
Budget expired: 503 with Retry-After: 1 |
TestVirtualRead_BudgetExpiryAnswers503WithAOneSecondWait |
Local cache-fill failure: 500, no cached copy |
TestVirtualRead_MetadataColdFillFailsLocallyAnswers500 |
AllUpstreamsIneligible / HeaderInjection: named 500s, never 404 |
virtual_read_errors_test.go mapper suite; the data-error half end to end in TestVirtualRead_DataErrorWithholdsTheNotFound |
Coalescing trio (SingleFlightTimeout / LeaderCanceled / FillAbandoned): 503 with Retry-After: 1 |
TestMavenVirtualReadErrors_CoalescingRowsShareOneRetryAfter |
Staging refusal: 503 with the jittered shed window |
TestServeVirtualRead_StagingRefusalAnswersTheShed503 |
Merge rows, 429 rate limit, PUT 405, 400/422 parse/FIPS rows |
not this step — Steps 12–14, 16b, and pre-existing S10 arms respectively |
Security considerations (this step's share of the spec's section)
| Consideration | Where it holds |
|---|---|
| Authorization is on the addressed virtual repository; no per-upstream check | the slot serves under the existing read-auth chain; denied upstreams stay indistinguishable (404 = absence), pinned by the well-formed-deny swap subtest of TestVirtualRead_DataErrorWithholdsTheNotFound |
| No new outbound surface | the winner's fetch runs through the S13 upstream client and S14 operations behind the facade; the slice issues no request of its own |
| Streaming bounds are the upstream's | body_size_cap_blob / body_size_cap_metadata bind the winner fetch; a read-time cap breach takes the transport answer (virtual_read_integration_test.go, size-cap subtest) |
| Read-path rate limit, merge XML hardening, merge memory budget | Steps 16b, 12–14, and 16a — not this step |
Governing ADRs
- ADR-001 / ADR-007 — every read and event is namespace-scoped; no schema change.
- ADR-004 — no limit surface; the read-path rate limit is Step 16b's.
- ADR-005 — artifact serves honor both delivery axes; metadata cold fills always stream.
- ADR-008 — cache serves open blobs by the row's content address, namespace-scoped.
- ADR-009 — no new route; the existing GET serves virtual repositories, writes stay gated.
- ADR-012 — both virtual events with their virtual-only dimensions; proxy fires after a clean copy, redirect on the issued 302, HEAD/304/206 fire nothing; the operator catalog gains the event and dimensions.
Tests
go build ./..., go test ./..., golangci-lint run, golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false ./internal/format/maven/ ./cmd/artifact-registry/, and the comment-cap gate pass. The integration suite (go test -tags=integration -run TestVirtualReadIntegration ./internal/format/maven/) passes against a testcontainers Postgres. Pre-existing wsl findings in remote_artifact_integration_test.go are in regions this diff does not touch. Validated end to end by five /validate-step passes against the plan, spec, and cited ADRs; the final pass reports no blockers.
User documentation
No docs/user/ surface changes: docs/user/repositories/_index.md already scopes virtual repositories as in development, and this step's classes resolve only where an association exists — the end-of-plan docs pass owns the customer-facing write-up.
Related to #289 (closed)