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:ResolveTimeoutonResolutionConfig.internal/config/remotemapping.go: map the value across, and drop the.Resolution.ResolveTimeoutentry fromvrFieldNotCrossingininternal/config/remotemapping_test.go.internal/virtual/resolve.go: arm the budget on entry withcontext.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.NewResolverrejects a non-positiveResolveTimeoutat boot, and aProbeTimeoutat or above it, beside the rejections it already performs.serveOutagestamps aresolution_endfield 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.classifyContextEndbranches oncontext.Cause, so a caller's own cancellation still answersErrResolverCancelledand 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: whyErrResolveBudgetExpiredis deliberately not inFallbackEligible'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 oferrors.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.
- "Released at winner selection" (
S13-virtual-remote-foundation.md:963and:1256). The budget is a scope over the decision's stages, not something a terminal path releases, which isdocs/plans/2026-08-27-s32-container-virtual.md's ruling under### The resolution deadline belongs in the resolver, not in either slice. - The expiry row's premise (
:1116): "Never404, because no position established an absence." A position whose probe answered404has 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 answers503"), and S13's own data-error row already rules the analogous incomplete-decision shape the same way: "serve the cached copy if one exists, otherwiseUpstreamUnavailable. NeverFileNotFoundOnUpstreams."
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.
- The client-visible
503withRetry-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.gonames no case forErrResolveBudgetExpired, so the sentinel reaches the500envelope.internal/format/npm/virtual_read_errors.mdandvirtual_operations.mdin 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). - 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 aBlobRefand no reader, so the body is opened outsideinternal/virtual. The cases assert the row and the validator instead. - The
transfer_idle_timeoutcompanion of the cold-fill criterion (criterion 4's second half). A fill running to a clean end of body past the budget is covered byTestResolve_ColdFillStreamingPastBudget_CompletesAndCaches. The same fill cut attransfer_idle_timeoutis not, and the knob is unimplemented rather than untested: at this head it appears in no Go file, proto or YAML, only underdocs/specs/anddocs/plans/.request_total_timeoutis 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)