feat(virtual): arm the resolution budget inside the resolver

What this does

resolution.resolve_timeout reached the configuration loader in #1069 (closed) and stopped there. Nothing read the value. This MR gives it a reader: the aggregate resolution budget becomes virtual.Resolver's own deadline.

  • internal/remote/config.go: ResolveTimeout on ResolutionConfig.
  • internal/config/remotemapping.go: map the value across, and drop the .Resolution.ResolveTimeout entry from vrFieldNotCrossing in internal/config/remotemapping_test.go.
  • internal/virtual/resolve.go: arm the budget on entry with context.WithTimeoutCause, scoped to the eligibility and health gate plus Phases 1 and 2. Every serve arm still takes the caller's context, so the winner's artifact-body transfer, a single-flight follower's wait for its leader's commit, and a warm cache serve the caller is still reading all sit outside the budget.
  • NewResolver rejects a non-positive ResolveTimeout at boot, and a ProbeTimeout at or above it, beside the rejections it already performs.
  • serveOutage stamps a resolution_end field on both records it can write: the served-from-cache one, and the terminal one that answers the verdict with nothing cached. An expiry and an upstream outage stay apart in the log on either path.
  • internal/remote/errors.go: ErrResolveBudgetExpired. classifyContextEnd branches on context.Cause, so a caller's own cancellation still answers ErrResolverCancelled and carries no response. An expiry answers from the decision the phases did reach: a winner they determined, else a cached copy on a consulted position, and the budget verdict only when neither exists.
  • internal/virtual/resolve_budget.md: why the budget is built where it is, and what it leaves out on purpose.
  • internal/remote/errors.md: why ErrResolveBudgetExpired is deliberately not in FallbackEligible's exclusion arm, and what adding it would owe. The arm is unchanged here and the change is tracked in #1330: a tenth value reddens the npm taxonomy test that AST-parses the arm out of errors.go, so it needs an npm mapping row this MR does not carry.

What the budget does not bound, and cannot

The reads that load the upstream list are outside it. The list reaches Resolve as Request.Upstreams, so the resolver never issues those reads and no change to virtual.Resolver can bring them inside. In internal/format/npm, VirtualUpstreamAssembly.Assemble reads the list, reads the rules and prefetches Phase 1's lookups before VirtualResolution.Resolve calls the resolver, and the health read loadAndFilter performs is answered from the memo Assemble seeded. So the only leg an npm read spends inside this budget today is Phase 2's sweep.

Seven surfaces claimed otherwise and are corrected here: both ResolveTimeout field docs (internal/remote/config.go and internal/config/virtualrepositories.go), config.example.yaml, the resolve_timeout row in docs/dev/configuration-reference.md, internal/format/npm/virtual_operations.md, whose per-stage list had the upstream-list read sitting under the budget, and in proto/artifactregistry/config/v1/config.proto both the ResolutionConfig message doc and the resolve_timeout field doc under it. gen/artifactregistry/config/v1/config.pb.go carries the last two as regenerated mirrors. The configuration reference is the one that mattered most, being user-facing.

resolve_timeout also has to stay above probe_timeout, because probeOne narrows each probe to the earlier of the two. NewResolver is what rejects that ordering, through errProbeTimeoutNotBelowResolveTimeout, naming both durations. The loader carries no counterpart: a configuration file setting probe_timeout at or above resolve_timeout loads, and the refusal happens where a resolver is built over the pair.

An earlier revision of this MR rejected the ordering at configuration load instead. That placement made a deployed override a boot failure rather than a misconfiguration: resolve_timeout is not new here, main already parses it, so an environment that raised probe_timeout on an earlier release could carry the pair already, and every pod of this release would exit at config load, before it binds, for a field no route reads yet. !2169 (merged) settled the same question the same way for the periodic workers' cadence ceilings: the config-load arms were deleted in 69bd140e in favour of a bound applied where the value is consumed, and docs/dev/background-jobs.md records that as the pattern. Refusing at construction keeps the invariant without making a stale override fatal at boot in the release that introduces the check.

Coverage moved with it. TestNewResolver_Validation gains the equal and above rejections plus the one-millisecond-below case that pins the open end, and internal/config/virtualrepositories_resolvetimeout_test.go now pins that the loader accepts the pair, so a loader arm reintroduced later reddens a case rather than passing unnoticed.

No client-visible change lands with this MR

No production path composes virtual.Resolver yet. At 85530efe0, git grep 'virtual.NewResolver(' finds no call site outside tests: the three NewResolver calls in cmd/artifact-registry/wire_*.go build maven.Resolver, npm.Resolver and namespace.Resolver, which are different types. The composition root is S31 Step 14 (#895), still open. Until it lands the budget is armed only in tests, so no request can reach it.

Where this deviates from S13, and who lands the wording

Two S13 sentences describe a mechanism this MR deliberately does not implement, and the merged S32 plan is what overrules them. The amendment is !2623 (merged), open and targeting main. Land !2623 (merged) first, because it closes the deviation this section declares; this MR's rebase then keeps its proto and generated mirrors, which !2623 (merged) does not touch. The two no longer share a file: at !2623 (merged)'s head ab8347762 it touches docs/specs/S13-virtual-remote-foundation.md and docs/specs/S31-npm-virtual.md only, and git merge-tree between the two branches is clean. This section used to record a conflict on internal/config/virtualrepositories.go, where both rewrote the same two comment lines of the ResolveTimeout field doc; !2623 (merged)'s ab8347762 ("restore the ResolveTimeout comment to main's wording") backed that hunk out, so the order now rests on the deviation alone.

  1. "Released at winner selection" (S13-virtual-remote-foundation.md:963 and :1256). The budget is a scope over the decision's stages, not something a terminal path releases, which is docs/plans/2026-08-27-s32-container-virtual.md's ruling under ### The resolution deadline belongs in the resolver, not in either slice.
  2. The expiry row's premise (:1116): "Never 404, because no position established an absence." A position whose probe answered 404 has established one for itself, and the expiry arm can be reached in that state. The row's verdict still holds; its stated reason does not. The plan rules the cache fall-through in at ### The resolution deadline belongs in the resolver, not in either slice ("A blown budget still tries the cache before it answers 503"), and S13's own data-error row already rules the analogous incomplete-decision shape the same way: "serve the cached copy if one exists, otherwise UpstreamUnavailable. Never FileNotFoundOnUpstreams."

What this does not reach

Three parts of #1139 (closed)'s ## Acceptance are not here, which is why the trailer is Related to and not Closes.

  1. The client-visible 503 with Retry-After: 1 (criterion 1's response half). The verdict is produced and tested, but no format layer maps it. internal/format/npm/virtual_read_errors.go names no case for ErrResolveBudgetExpired, so the sentinel reaches the 500 envelope. internal/format/npm/virtual_read_errors.md and virtual_operations.md in this MR say exactly that, and name the mapping case as what satisfies S31's Budget-expiry Error Cases row. Owner: S31 Step 13 (#894) for the mapping, with the route that reaches it owed by Step 14 (#895).
  2. A warm cache serve delivering a whole body past the budget (criterion 3). No test here delivers that body and none in this package can: a cache serve returns remote.LookupResult, which carries a BlobRef and no reader, so the body is opened outside internal/virtual. The cases assert the row and the validator instead.
  3. The transfer_idle_timeout companion of the cold-fill criterion (criterion 4's second half). A fill running to a clean end of body past the budget is covered by TestResolve_ColdFillStreamingPastBudget_CompletesAndCaches. The same fill cut at transfer_idle_timeout is not, and the knob is unimplemented rather than untested: at this head it appears in no Go file, proto or YAML, only under docs/specs/ and docs/plans/. request_total_timeout is today's bound on the transfer, and #269 owns the progress-based deadline that would replace it.

Nothing in this package makes the budget's verdict deliverable either. Go arms the connection write deadline once at server.timeouts.write (10s in config.example.yaml), so a resolution that spends its budget answers past it unless the route arming its own deadline counted this value in. The expressions that do, and which step owns each, are in the S32 plan under ### Every virtual route arms its own write deadline, and the budget is stated against it; resolve_budget.md points there.

Merge order

The pair is this MR and S32's Step 11, and the overlap is wider than one field. Step 11's Files list books six entries this MR lands: internal/virtual/resolve.go's budget, internal/remote/config.go's ResolveTimeout, internal/config/remotemapping.go, internal/remote/errors.go's sentinel, remotemapping_test.go's vrFieldNotCrossing drop, and the one-sentence corrections to docs/dev/configuration-reference.md and config.example.yaml, whose own bullet says "no other step books the correction", which this MR makes false. This MR is the first of the pair, so Step 11 should consume all six rather than re-declare them. That bullet is corrected in !2624 (merged), open and targeting main: guardrail 4 keeps a step MR out of the plan file, so it could not ride here. The order is recorded in Step 11's Shared seams in docs/plans/2026-08-27-s32-container-virtual.md and from the other side in docs/plans/2026-09-04-virtual-collection-mode.md.

Not gated on #1069 (closed), which has merged.

Depends on !2627 (merged) (dmeshcharakou/s04a-step10-credential-flip), which merges first. !2627 (merged) is step 10/11 of the S04-A per-format columns plan, so its remaining step waits behind it, and holding it costs more than holding this MR. Both merge requests have the same author, so the order only needs recording, not coordinating. While both are open, whichever merges second resolves internal/virtual/resolve.go and internal/virtual/resolve_errors.md, both in runPhase2's probe-error handling. Once !2627 (merged) lands, this branch rebases onto main and resolves those two files. Measured with git merge-tree --write-tree --name-only at this MR's 1a692d45f, !2627 (merged)'s 7fcde61ce and main at ce127a8c3, merge base 85530efe0: the two branches share five files and only those two conflict. docs/dev/configuration-reference.md, internal/format/npm/virtual_assembly_test.go and internal/virtual/resolve_test.go auto-merge. Each branch merges cleanly into main on its own, which is why no pipeline reports the pair.

File overlap over this MR's 21 files, re-derived with git merge-tree at 1a692d45f against main at 45b52905d and the 74 other open merge requests: four overlap. !2496 (merged) (config.example.yaml, docs/dev/configuration-reference.md) and !2514 (merged) (internal/remote/errors.md) merge cleanly. !2627 (merged) conflicts on internal/virtual/resolve.go and internal/virtual/resolve_errors.md, both in runPhase2's probe-error handling; it merges cleanly into main, so the conflict belongs to this pair alone and whichever of the two lands second resolves both files. !2332 conflicts on docs/dev/configuration-reference.md and conflicts with main as well, so it needs a rebase whichever way this MR goes. !2623 (merged) no longer overlaps at all, for the reason "Where this deviates from S13" above gives. !1598 and !1554 are fork merge requests whose branches are not on origin, so they were not checked.

Why no plan MR

The mechanism is already planned. The section ### The resolution deadline belongs in the resolver, not in either slice of the merged docs/plans/2026-08-27-s32-container-virtual.md carries it and the reasons, and two merged plans name this work as "#1139 (closed)'s S13 MR" rather than as a step of either plan. The title therefore carries no plan step marker.

Tests

internal/virtual/resolve_budget_test.go adds 12 tests: the verdict split between an expiry and a caller's abandonment, expiry during the eligibility and health gate, during Phase 1, and with every probe failed positionally, an expiry answered from a consulted cached copy, an expiry answered from a winner Phase 1 had already determined, an expiry before a revalidated serve, the winner's fetch getting the caller's context rather than the budget's, a cold fill that streams past the budget and still publishes its row, an expiry with nothing cached at any consulted position, which asserts the resolution_end value on the record that answers the verdict, and two follower cases bounded by single_flight_wait_timeout alone. internal/config/remotemapping_test.go gives ResolveTimeout a distinct non-zero input and asserts the mapped value, and ErrResolveBudgetExpired gets its row in internal/remote/errors_test.go's TestSentinelErrors_IsRoundTrip, whose doc covers every service-layer sentinel.

Every row of the verdict-split table asserts the site classifyContextEnd names, and TestNewResolver_Validation asserts which rejection fired rather than only that one did.

The budget suite was built on the ordering NewResolver now refuses: a 5s probe timeout under a 100ms budget, so a parked probe woke on the budget with a 50x margin. Holding the probe timeout below the budget inverts that, and the budget arm now needs a position the sweep reaches with less than a probe timeout left. budgetResolver caps the fan-out at one so positions run in sequence, budgetSpentPosition spends the first probe timeout ahead of the scripted position, and at 60ms against 100ms the second probe starts with 40ms left and wakes on the budget. That margin is 1.5x rather than 50x, so every case built this way runs inside testing/synctest: the two deadlines land in a fixed order on a virtual clock, which is what keeps the narrower margin from being a wall-clock race. Seven cases moved, and assertBudgetBoundedPhase2 went vacuous under the new ordering, so it is now assertBoundedPhase2 and measures the bound the composed cases do carry.

No conformance run: this MR implements no Maven, npm or Container/OCI protocol behaviour. No e2e scenario in docs/testing/ is added or affected, because nothing composes the resolver on a request path yet, so there is no client-visible behaviour to describe. The scenario belongs with the mapping and the route named in "What this does not reach".

Reviewable size

1751 reviewable LOC at bf8360d64, past the 500 the development model asks about. The split:

Group Added Removed Total
Tests (*_test.go) 1009 11 1020
Docs and design notes (*.md, config.example.yaml) 434 47 481
Mechanism (internal/virtual/resolve.go, internal/remote/*.go) 131 89 220
Config, proto and generated stub 15 15 30

The mechanism itself is 220 LOC. Splitting would not help: the tests are the acceptance criteria, and holding them back would land either an untested mechanism or a red suite. The field, the scope, the construction-time rejection and the verdict split are one change, since each one alone leaves the suite failing.

Related to #1139 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading