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-derived ETag and 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_unavailable where no cache entry stands, the coalescing 503 with Retry-After: 1, the staging-refusal row's jittered 503, abandonment unanswered, and a local failure mapped through the usual 500. An artifact-class HEAD cold 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 Virtual mark suppresses the inner hosted emission; the delivered pull books repository_kind virtual, the virtual repository's id, and the cache_hit dimension. artifact_registry_virtual_cache_miss books when the serve needs the winner's own bytes — the hosted origin or a cold remote fill — with upstream_type naming the winner's kind, so a warm copy's hit books the pull alone.

  • A remote winner's warm serve moves the shared downloads_count under the delivery gate, and the same delivered arms bump the remote rows' last_downloaded_at.

  • The package-level metadata and sidecar classes keep writeNotImplemented until 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. VirtualPosition carries the rows the resolution already paid for — the batched hosted maven_files row, the maven_repositories.id the verdict matched on, and the remote position's lookup verdict (virtual_assembly.go) — and the facade keeps the virtual.Request it resolved on, resolveDrainFailure calling 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 squash 888d50176 under merge commit 5fe1e4db3), so this MR targets main and the diff is the handler and its suites only.
  • Merged main: the resolver's drain-fallback entry point (#1434 (closed)) is on main.

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 HEAD cold fill drains before it writes anything (its drain failure reaches the entry point rather than mapping straight to 503); the remote winner's warm serve moves downloads_count under 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 by 061dbffd2.
  • 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's cache_hit records the other half of that verdict. The plan clause carrying the verdict rule is corrected by e8fcaded8.
  • 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 the HEAD drain and the metadata fill evaluates the registry's validator (b17bf5327); a vanished blob answers the remote winner's 503 rather than the hosted arm's 404 (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 ETag and length with another blob's X-Checksum-Sha1 — the digest Maven Resolver trusts over the .sha1 sidecar. 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)

Edited by Moaz Khalifa

Merge request reports

Loading
Loading