feat(npm): resolver virtual tier (S31 plan: 9/19)
What this changes
npm.Resolver walked two tiers and then a third for kind=2 only, so a
kind=1 request reached the dispatcher with nothing but its parent row. The
virtual read path in internal/virtual has been merged since S13 and has had no
caller, and Step 10 dispatches on the row this tier produces.
Resolution gains a Virtual field and the resolver a
VirtualRepositoryFinder, mirroring Remote and RemoteRepositoryFinder arm
for arm:
| Kind | Third tier | Field populated |
|---|---|---|
kind=0 hosted |
none | neither |
kind=1 virtual |
VirtualRepositoryFinder.FindVirtualByRepositoryID |
Virtual |
kind=2 remote |
RemoteRepositoryFinder.FindRemoteByRepositoryID |
Remote |
The finder is internal/datastore/npm_virtual_repository_finder.go, read-only,
keyed on the repositories.id tier 2 resolved rather than on the name a second
time. It composes the shared activeParentPredicate with a new
npmVirtualParent spec, so the read gate and the write gate cannot drift about
what an active npm virtual parent is. NewResolver takes a fourth required
finder and panics on nil, which is why cmd/artifact-registry/wire_npm.go and
the four npm.NewResolver call-site test files are in this step: without them
the tree does not compile.
The file is deliberately not named npm_virtual_repositories.go. S17 Phase 6
Step 37 creates that file for the child-row create and delete, both steps are
gated on Step 1, so both can be in flight at once and sharing the name would
conflict across the whole body of whichever landed second. The issue records
the same reasoning.
The kind=1 answer moves from 501 to 404, and no repository can observe it
Before this MR a kind=1 npm repository resolved successfully, reached
dispatchByKind, and got the interim 501 not_implemented. After it, a
kind=1 parent without its npm_virtual_repositories child row fails the
resolve as ErrRepositoryNotFound, so the middleware answers 404 repository_not_found plus one Info record.
Nothing can reach that transition today, and the reason is worth stating rather than assuming:
repositoryCreateKindGaterefuses everykind=virtualcreate withErrRepositoryCreateVirtualKind(internal/datastore/repositories.go:1695).- The management API rejects
kind: "virtual"with a422before that, and its predecessor accepted onlyhosted. RepositoryStore.Updatenever writeskind.- No production code writes
npm_virtual_repositoriesat all; this finder is its only reader.
So the shape this tier detects is reachable only by direct insert, or by a
parent tombstoned between the two reads, which activeParentPredicate's
soft-delete arm then rejects. During a rolling deploy the same
request would get 501 from an old pod and 404 from a new one, unreachable
for the same reason. The tier goes live when the virtual create path merges,
which is Step 37 in a different plan and stack, so the merge order is
recorded here rather than implied by a branch.
resolver.md states the condition and the event on both third-tier bullets,
rather than the present tense, so it reads correctly on either side of that
merge.
Five files beyond the issue's Files list
Each is justified, and each is named in a commit body already:
| File | Why |
|---|---|
internal/format/npm/middleware.go |
resolveErrorCode needs nil_virtual_resolution as a distinct closed-set value, so the two contract breaches stay separable without matching message text |
internal/format/npm/resolver.md |
the tier walk and the error contract outgrew the comment caps; three npm sidecars already exist |
internal/datastore/npm_virtual_repository_finder_guards_test.go |
the argument, zero-value and construction guards, which touch no database and so carry no build tag |
cmd/artifact-registry/wire_npm_test.go |
the adapter's error translation, mirroring the remote adapter's test |
internal/datastore/repository_parent_gate_integration_test.go |
added in the review round, below |
No drift against the spec: the npm_virtual_repositories section of
S31
specifies exactly the three-column table and the
(namespace_id, repository_id) unique index this finder keys on, and its "a
virtual repository stores no cached content and no credentials" holds for
NpmVirtualRepositoryResolution, which carries two ids and nothing else.
Governing ADRs
- ADR-007. Soft deletion sits on the parent so every kind shares one
deletion semantics, which is what the finder's parent gate applies. Its
npm_virtual_repositoriessection specifies the(namespace_id, repository_id)unique index for looking a virtual repository up by its parent reference, which is exactly the key. - ADR-001. Namespace scoping is pinned on both sides of the join, the child
from its own predicate and
repositoriesfrom insideactiveParentPredicate. A cross-namespace subtest asserts it. - ADR-023. Locked paths respected: datastore file in
internal/datastore/, interface ininternal/format/npm/, adapter incmd/artifact-registry/, no raw SQL in production code, no cross-format import.
./scripts/adr-freshness.sh reports the mirror stale by three upstream
commits, ba269c554, 9a56bb4d5 and 3640b6d88, all touching
007_database_schema.md. They cover the size_bytes decrement at the delete,
a maven_packages index for tombstone discovery, and artifact-level walks
reading the namespace shadow, none of which this change goes near; ADR-007's npm_virtual_repositories shape and its
(namespace_id, repository_id) unique index match the finder's key as the
mirror already has them. One internal ADR never mirrors locally, so a reviewer
with handbook access should confirm against it.
Queries
One statement, npm_virtual_repositories_select_by_repository_id, unique in the
catalog and instrumented through the standard instrumentQuery seam that
TestEveryStatementIsInstrumented enforces.
WHERE (namespace_id, repository_id) = ($1, $2) rides
unique_nvr_ns_id_repository_id, so at most one row can match, and LIMIT(1)
is a literal with no caller-fed provenance. Both partitioned sides carry
HASH(namespace_id) as a constant, the child from its own predicate and
repositories from inside the shared gate, so each prunes to one of 64
partitions.
TestNpmVirtualRepositoryFinder_PlanShape EXPLAINs the same constructor the
store executes and asserts single-partition pruning on both sides, which is
parity with the remote sibling's
TestNpmRemoteRepositoryStore_FindRemoteByRepositoryID_PrunesToOnePartition.
Neither asserts index usage, so no coverage was dropped in the mirroring.
Only qrm.ErrNoRows maps to ErrNotFound, so a database outage on this tier
surfaces as a 500 rather than a cacheable 404. An integration subtest pins
that with a cancelled context.
One number to watch, not caused here: internal/metrics/cardinality.go budgets
labelName at 500, and the two catalogs now hold 473 and 12, so this MR moves
the count to 485. Nothing counts either catalog against the budget and the audit
sees only observed values, so the ceiling would be crossed in production rather
than by a failing test. Raising it needs a docs/specs/S03-b-metrics.md
amendment in the same change, since that table still records up to ~200 for
name and the budget map's header cites it as the source. This branch tried
the raise and reverted it; see the third round below.
Tests
TestResolver_Resolve_VirtualTierByKind: all three kinds as positive rows with call counts. The hosted and remote happy paths both assertvirtualFinder.callCount == 0, so a tier that fired for the wrong kind fails rather than passing quietly.- All five remote-tier resolver tests have virtual twins, including the
(nil, nil)contract breach, which fails closed on its own sentinel rather than building a nil-Virtualresolution. - The finder's four guards each have their own sentinel assertion, and the
constructor has both the nil-panic and a positive "keeps the client" case, so
the panic cannot be satisfied by a constructor that always panics. The zero
value is pinned as a named error rather than a nil-client dereference, per the
exported-zero-value rule in
AGENTS.md. - The integration suite was diffed subtest by subtest against
TestNpmRemoteRepositoryStore_FindRemoteByRepositoryIDfor dropped subtests, not only added ones. None dropped; the only unmirrored cases are the two auth-token ones, and this table has no token column. Three are added: thenpm_repositoriessibling row the create path writes, the absent child row, and a maven parent carrying an npm virtual binding. - Both non-virtual
kindvalues get positive rejection cases rather than one standing for both, which is the enumerated-column rule. - The tier-miss records are asserted on level, exact message,
tier, and both id fields.remoteTierMiss.msgis byte-identical to the literal it replaced, so existing operator log queries still match.
e2e scenarios
None added or affected. The plan assigns both docs/testing/e2e/npm.md and the
mise run conformance:npm harness to Step 19, which it calls the plan's single
owning step for the catalog, and this step changes no reachable npm
wire-protocol behavior.
Reviewable LOC
1525 across 15 files, over the 500 guidance, so here is the split by group.
Re-derived from git diff origin/main...HEAD --numstat at 9583fc228:
| Group | Added | Removed | Total |
|---|---|---|---|
| Production Go (5 files) | 286 | 102 | 388 |
| Tests (8 files) | 854 | 78 | 932 |
| Docs (2 sidecars) | 205 | 0 | 205 |
The plan's "How the ceiling is applied" section applies the ceiling to reviewable source following the S15 measurement, under which 388 is well inside it. Test-to-source ratio on added lines is 2.99:1.
internal/metrics/cardinality.go is no longer in the diff. The second round
raised its query-name budget and the third round reverted that, so the two
commits cancel and the file's net change is empty.
The issue estimated Source ~230 and Test ~520. Source came in near it; tests ran over, mostly in the two integration suites and the by-kind matrix.
File overlap with open merge requests
git diff --name-only origin/main...HEAD checked against all 74 open MRs. Seven
share a file. Only one shares a line:
| MR | Shared file | Hunks |
|---|---|---|
| !2057 (merged) | internal/format/npm/resolver.go |
theirs old 110-116; mine inserts at old 109. Adjacent; conflicted before the rebase, clean now |
| !2059 (merged), !2051 (merged), !2040 (merged), !2035 (merged), !2030 (merged) | internal/datastore/query_names.go |
theirs 54, 185-200, 200-215, 312-319, 372-384, 405; mine inserts at 558. Disjoint |
| !1011 (closed) (Draft) | cmd/artifact-registry/wire_npm.go, wire_npm_test.go |
theirs 223-229, 264-270, 300-306; mine 102, 351, 629, and 320. Disjoint |
The resolver.go overlap was a real conflict, from main's movement rather
than !2057 (merged)'s: DeliveryModeOverride merged into the tail of Resolution after
this branch's old merge base, and this branch appends Virtual to the same
position. The branch has since been rebased onto main, and the resolution is
the one this section anticipated: Resolution carries both fields, they are
independent, and neither comment refers to the other.
git merge-tree --write-tree origin/main HEAD now exits 0, so there is no
conflict left to resolve and no merge order to respect against !2057 (merged), which
also merges cleanly against both this branch and main. The rebase carried one
conflict that was not textual: main added
TestResolver_Resolve_CarriesDeliveryModeOverride, which calls
npm.NewResolver with three finders, and this branch makes it four. That call
site was fixed in the commit that introduces the fourth parameter, so no commit
on the branch is left with a call that does not compile.
What the review round changed
Two commits after the implementation, both behaviour-preserving:
docs(npm): complete the resolver third-tier enumerations. The tier turned four comments into half-lists. Two were fixed when it landed; these are the other two.ResolverandNewResolverstill named the remote row and the remote finder alone, and both blocks predate the comment caps and were not in the tier's own diff, socheck-comment-caps.shgrandfathered them and nothing flagged them. Touching them takes each to the 3-line exported cap, which is where the(resolver.md)pointer earns its place. The same commit fixes theresolver.mdframing described above, including a parity claim that borrowed a precedent whose create path does the opposite of what the sentence needed.test(datastore): drive every production parent spec through the gate net.repository_parent_gate.goclaimsTestParentRepositoryIsActivedrives every production spec against a row seeded from independent literals, which is what covers a transposed constant inside aparentFormat/parentKindconversion: the rendered SQL is identical for every pair and only the bound args differ.npmVirtualParenthad no row, so the claim went false and the npm/virtual pair had no positive hit at the gate level. The maven row was an older gap in the same claim, building its own literal rather than bindingmavenRemoteParent, so a transposition inside that declaration was never reached. All three production specs are now driven and the sentence is true as written.
Deferred, with reasons
activeParentPredicate's composer list. Its doc names "the npm remote reads" and this MR adds a composer it does not name. The drift is older than this MR, since the maven credential and details reads already compose it too. Fixing it in place would force all 14 lines of that block to the 3-line cap, so it still belongs in its ownrefactorMR. What this MR does add isinternal/datastore/repository_parent_gate.md, which states both consumer shapes correctly, so the list a reader can reach is no longer the wrong one.- The third round trip itself.
npm_virtual_repositoriesis three columns, all keys, and the statement's only product is the binding surrogate plus the fact that it exists; its parent gate re-establishes format, kind and soft-delete state that tier 2 already settled. Cost is zero today becausekind=1is unreachable, and the query is currently unconsumed as well as unreached: nothing outside the resolver's own nil-invariant check readsResolution.Virtual, anddispatchByKindanswerskind=1with501. Step 10 turns the row into an answer, and inherits a query already paid for rather than adding one. The alternative is toLEFT JOINthe binding into tier 2's statement, which is the rationale the plan's Step 4 uses fornpm_repositories("a thirdLEFT JOIN npm_repositories, which costs no statement"). Worth deciding deliberately, with a measurement, before virtual read traffic ships rather than inheriting it; the seam mirrors the shipped remote tier and uniformity across tiers has real value.
What the second review round changed
A /review-branch pass over the rebased branch returned no blocking findings.
Security, reliability and API compatibility were clean. Everything it did raise
was in prose the code ships with, or in this description, and all of it is
fixed here:
docs(npm): name every reading the tier-3 not-found list leaves open. The tier 3 list inresolver.mdannounced itself as complete and then asserted thekind=2miss "comes from a hard-deleted parent". Both third-tier reads composeactiveParentPredicate, whose soft-delete arm rejects a parent tombstoned between tier 2 and tier 3, andRepositoryStore.SoftDeletehas no kind gate and is reachable fromDELETE /repositories/{id}, so the tombstone race is the cause actually reachable in production and it was the one omitted.feat(npm): give the tier-miss records a structured tier field. Lifts the deferral above.logTierMisstakes atierMissRecordpairing each pinned message with itstiervalue, so the two cannot drift, and emitstierbeside the ids. The message text is unchanged, because it is the wire format an existing log query matches. The remote tier's comment came to the two-line function-body cap in the same pass: it still said "a second tier" and "the second query", the numbering the branch had already moved off.docs(datastore): move the parent gate suite's reasoning to a sidecar.TestParentRepositoryIsActive's header called npm remote the gate's "only production caller today"; there are two, andnpmVirtualParentis not a caller of the write gate at all. The_test.gocap is two lines, so the seeding rule and the transposition gap move torepository_parent_gate.mdrather than being deleted. The refusal cases also stopped hand-rolling aparentSpecthe positive case binds from production.test(datastore): pin the virtual finder's index condition. The plan-shape test asserted partition pruning and stopped, so a predicate change that defeatedunique_nvr_ns_id_repository_idand fell back to a partition-local sequential scan would still have passed. It now also requires anIndex Condbinding both key columns, through the suite's existingidKeyedChildIndexCondReandexplainAnalyzeStmt.chore(metrics): raise the query-name label budget. TooklabelNamefrom 500 to 800. Reverted in the third round below, so the branch leaves the budget where it found it. Both commits stay in the history, because the project merges rather than squashes.
Four commit messages were also reworded, since they reach main verbatim under
merge commits. The feat(npm): resolver virtual tier body said "Three files the
plan's Step 9 list does not name" and led with query_names.go, which the plan
names at line 1988; two commits carried a bare refactor: against a history
that scopes it; and the partition-pruning commit was scoped npm for a change
that lands in internal/datastore.
One item is outside the diff: the AppSec reviewer session (6720773) posted
"failed (dropped)" three minutes after starting and left no findings thread, so
its silence is not a pass. It has been re-triggered.
What the third review round changed
A second /review-branch pass, over 1f476618d, returned no blocking
findings. Security, reliability, performance and API compatibility were clean.
It raised four warnings, none of which changes request behaviour, and they
are answered here.
docs(npm): describe the tier-miss records the code emits.logTierMiss's doc comment points atresolver.md, and that file still described the helper as "called with the tier's own message constant" and never mentioned thetierfield at all. The second round replaced the constants withtierMissRecordand added the field, so a reader following the pointer got a contract this branch had already replaced.docs(datastore): correct the parent gate sidecar's two claims. The read-path bullet namednpm_virtual_repository_finder.goalone, whileactiveParentPredicatehas seven composition sites across six files, and the sibling bullet's outright count ("There are two today") made the asymmetry read as a complete list. That leftnpmRemoteParentandmavenRemoteParentlooking write-path only, whichactiveParentPredicate's own doc comment contradicts. The same commit fixes the transposition paragraph, which read as though the maven-remote and npm-virtual rows compensate for the npm remote pair's blind spot; each row only catches a transposition in its own declaration.test: fix two stale references in the virtual tier suites. The remote tier-miss assertions still said "the warn" about a record the same block pins atslog.LevelInfo, and the plan-shape test credited theANALYZEwith pinningenable_seqscan=off, which isexplainAnalyzeStmt's doing.revert(metrics): restore the query-name label budget to 500. The raise put the entry four times abovedocs/specs/S03-b-metrics.md, whose table recordsup to ~200forname, while the budget map's own header claims the counts come from that spec and every other entry matches its row. The branch needs no headroom, adding one name against 485 declared and a 500 ceiling, so the raise goes back out and belongs in a spec amendment with the growth argument attached.
The fourth item the pass raised was the ## Database Review Evidence section
the plan requires for this step in query mode. It is present, at the end of
this description.
Why this closes the issue
Every item of the issue's ## Acceptance has evidence here: a kind=1
repository resolves with a populated Virtual field
(TestResolver_Resolve_VirtualTierByKind), kind=0 and kind=2 leave it nil
(same test, with call counts), and a kind=1 repository with no child row fails
rather than resolving with a nil field (TestResolver_Resolve_VirtualBindingMissing
and the finder's own absent-child subtest).
One reading to flag, because the wording invites it. The acceptance says such a
repository "fails loudly". What ships is an existence-hiding 404 plus one
Info record, not a loud failure. The clause's own contrast is with "resolving
with a nil field", and on that reading it is satisfied; Warn was rejected
because a concurrent delete produces the identical record, so it would alert on
a race that works as designed, and resolver.md records that. If you read
"loudly" as requiring more than an Info record, say so and this becomes
Related to.
The plan row lands elsewhere
Step MRs do not edit the plan file, so the Status table row for step 9 goes in a
docs(plans) MR rather than here.
Closes #890 (closed)
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17 container (matching GL_PG_CURR_VERSION from
.gitlab-ci-other-versions.yml; server reported 17.11), with synthesized
seed data rolled back per query and the container torn down at the end of
the run. Numbers reflect moderate cardinality and do not capture
production-scale effects. See
Database review evidence
for seed sizing, methodology, and the anomalies the skill flags.
Expand the row's details for the seed shape, rendered SQL, bound args,
and raw plan.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.FindVirtualByRepositoryID |
Limit | npm_virtual_repositories_p43_namespace_id_repository_id_idx (partition-local unique_nvr_ns_id_repository_id), repositories_p43_pkey (partition-local pk_repositories) |
1 / 1 | 16.62 | 0.011ms | 6 / 0 | 1/64, 1/64 |
datastore.FindVirtualByRepositoryID
Summary: Plan matches the method's intent. A Limit over a Nested Loop: the (namespace_id, repository_id) bind drives an Index Scan on the partition-local instance of the unique index unique_nvr_ns_id_repository_id, and the joined repositories row is fetched by primary key with the shared parent gate (format = 2, kind = 1, soft_deleted_at IS NULL) applied as a Filter. Both hash-partitioned sides prune to one partition of 64 (_p43 on each), the estimate matches reality at 1 / 1 row, and execution is 0.021 ms against 5000 seeded rows per side with read=0 buffers. No anomalies.
One property worth naming, which this statement does not cause and cannot avoid: planning dominates execution here, because the chain is two tables of 64 partitions each and the pgx query mode is QueryExecModeSimpleProtocol (labkit's default, which PgBouncer transaction pooling requires), so pruning is planned on every execution rather than once. Read the absolute planning number below as orientation, not as a production cost: it comes from a single run in a laptop container, and TestContainerRemoteCacheStore_ChainWidth documents that repeated sampling of this shape does not converge.
Seed shape: namespaces=1, repositories=5000, npm_virtual_repositories=5000
All seeded rows share one namespace_id, so both hash-partitioned tables put
every row in a single partition. The 5000 repositories rows are all
npm-format, kind=virtual, and active, which is the production shape for a row
this statement is meant to find and the least flattering one for the read: the
parent gate discriminates nothing, so the index carries the whole lookup. Each
repositories row carries one npm_virtual_repositories child. The bound
repository_id is taken from the middle of the seeded range.
Rendered SQL:
SELECT npm_virtual_repositories.id AS "npm_virtual_repositories.id",
npm_virtual_repositories.repository_id AS "npm_virtual_repositories.repository_id"
FROM public.npm_virtual_repositories
INNER JOIN public.repositories ON ((repositories.id = npm_virtual_repositories.repository_id) AND (repositories.namespace_id = npm_virtual_repositories.namespace_id))
WHERE ((npm_virtual_repositories.namespace_id = $1::uuid) AND (npm_virtual_repositories.repository_id = $2::uuid)) AND ((((repositories.namespace_id = $3::uuid) AND (repositories.format = $4)) AND (repositories.kind = $5)) AND (repositories.soft_deleted_at IS NULL))
LIMIT $6;Bound args: [e3b6c55d-a0e1-434f-b0b3-158af2deab59, 5dc8dbf2-e0e7-4dde-b508-c06c845de80a, e3b6c55d-a0e1-434f-b0b3-158af2deab59, 2, 1, 1]
$4 is RepositoryFormatNpm and $5 is RepositoryKindVirtual, both supplied
by the shared parent gate rather than by the caller. $6 is the LIMIT(1)
literal, which go-jet renders as a positional placeholder.
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.56..16.62 rows=1 width=32) (actual time=0.010..0.011 rows=1 loops=1)
Buffers: shared hit=6
-> Nested Loop (cost=0.56..16.62 rows=1 width=32) (actual time=0.010..0.010 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using npm_virtual_repositories_p43_namespace_id_repository_id_idx on npm_virtual_repositories_p43 npm_virtual_repositories (cost=0.28..8.30 rows=1 width=48) (actual time=0.005..0.005 rows=1 loops=1)
Index Cond: ((namespace_id = 'e3b6c55d-a0e1-434f-b0b3-158af2deab59'::uuid) AND (repository_id = '5dc8dbf2-e0e7-4dde-b508-c06c845de80a'::uuid))
Buffers: shared hit=3
-> Index Scan using repositories_p43_pkey on repositories_p43 repositories (cost=0.28..8.31 rows=1 width=32) (actual time=0.004..0.004 rows=1 loops=1)
Index Cond: ((id = '5dc8dbf2-e0e7-4dde-b508-c06c845de80a'::uuid) AND (namespace_id = 'e3b6c55d-a0e1-434f-b0b3-158af2deab59'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (format = '2'::smallint) AND (kind = '1'::smallint))
Buffers: shared hit=3
Planning:
Buffers: shared hit=643
Planning Time: 1.104 ms
Execution Time: 0.021 msTimings: planning 1.104ms, execution 0.021ms, total 1.125ms.
Migration mode did not run: this merge request adds no file under
internal/datastore/migrations/sql/.