feat(npm): remote package and version resolvers (S15 Step 5, part 2/4)
📚 Stacked MRs
S15 Step 5 — the read side of the npm-remote datastore layer — is split into 4 stacked MRs to keep each within the review size target (~600 reviewable LoC). Each part targets the previous one (part 1 targets main); review and merge proceed bottom-up. The four parts together supersede the change originally proposed in !1050 (closed), and have since taken review feedback on top of it.
Stacked MRs (review/merge bottom-up)
- feat(npm): datastore remote-repository finder (... (!1093 - merged) • David Fernandez • 19.3
- feat(npm): remote package and version resolvers... (!1094 - merged) • David Fernandez • 19.3
👈 - feat(npm): remote packument metadata cache read... (!1095 - merged) • David Fernandez • 19.3
- feat(npm): remote tarball cache reads (S15 Step... (!1096 - merged) • David Fernandez • 19.3
📦 What this adds (part 2 of 4)
The package and version resolvers: NpmRemotePackageByName (full @scope/name) and NpmRemoteVersionByPackageAndVersion resolve a cached package or version to its binding id so the metadata/tarball lookups can key on it. Each returns a named resolution type (NpmRemotePackageResolution, NpmRemoteVersionResolution) carrying just that id, per architecture.md's "return domain types, not database rows" and part 1's NpmRemoteRepositoryResolution; the jet row stays inside the store. Each gates its own soft_deleted_at; a not-yet-cached row is a cache Miss (ErrNotFound), never a distinct error. Also marks the hosted package guard table nolint:dupl for the intentionally-parallel remote sibling added here.
🧪 Spec coverage
Spec: docs/specs/S15-npm-remote.md
This part is the datastore read layer for the cached package and version
entities. S15's acceptance criteria are stated at the HTTP boundary, so most are
owned by later parts and steps; the rows below are the ones this MR's code can
satisfy. AC numbering follows the spec's own per-subsection numbering.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| Name resolution — cache Miss | "A read against a package with no cached row yet is a cache Miss, not a distinct error": an uncached package or version resolves as ErrNotFound, never a distinct error |
TestNpmRemotePackageStore_NpmRemotePackageByName: …when no package matches the name (cache miss); TestNpmRemoteVersionStore_NpmRemoteVersionByPackageAndVersion: …when no version matches (cache miss) |
| Name resolution — scoped packages | A scoped package resolves by its full @scope/name form, the name predicate carrying the @scope/ prefix |
resolves a scoped package by its full @scope/name form |
| Name resolution — case-sensitive | Names and versions are matched verbatim; the service does not normalize case at lookup time (S11 validation rules, reused unchanged) | name lookup is case-sensitive, version lookup is case-sensitive |
| Name resolution — namespace-scoped (ADR-001) | A cached package or version in one namespace never resolves under another namespace's id | does not resolve a package from a different namespace, does not resolve a version from a different namespace |
| Name resolution — repository- and package-scoped | Two remote repositories in one namespace may both proxy lodash, and two packages may both carry 4.17.21; each lookup is keyed by its parent binding id |
does not return a package from a different repository in the same namespace, does not match a version under a different package |
| Data model — binding-id resolution | Each read resolves to its binding surrogate (npm_remote_packages.id, npm_remote_versions.id), the id the child metadata-file and tarball-file lookups key on |
resolves an unscoped package to its binding id, resolves a cached version to its binding id (whole-struct cmp.Diff against the resolution, so a field added to it fails until asserted) |
| Data model — partial unique indexes | The partial condition "allows re-caching after soft deletion", so an active row coexisting with a soft-deleted one at the same key resolves to the active row | resolves to the active row when a soft-deleted row shares its key (both stores) |
| Data model — hash partitioning | namespace_id is pinned so each read prunes to 1 of 64 partitions on the hot packument and tarball paths |
TestNpmRemotePackageStore_NpmRemotePackageByName_PrunesToOnePartition, TestNpmRemoteVersionStore_NpmRemoteVersionByPackageAndVersion_PrunesToOnePartition (EXPLAIN over the store's own statement builder) |
| Packument proxy, dist-tags, Tarball proxy, Write unavailability, Credentials and health, Error mapping | Not in this MR — HTTP-boundary behavior owned by parts 3–4 and later steps |
Error cases
| # | Condition | Tests |
|---|---|---|
| Cache miss vs. soft-deleted | A soft-deleted package or version resolves as ErrNotFound, indistinguishable from never-cached, so a soft-deleted row is never served |
returns ErrNotFound for a soft-deleted package, returns ErrNotFound for a soft-deleted version |
| Transient DB failure | A non-qrm.ErrNoRows DB error surfaces as itself, never as ErrNotFound, so a transient failure does not drive a spurious upstream fetch |
a transient DB failure is not misreported as ErrNotFound (both stores) |
| Caller wiring mistakes | Nil context, zero-UUID namespace, zero-UUID parent id, and empty name/version each reject with the store's own sentinel before any DB access; a nil client panics at construction | TestNpmRemotePackageStore_NpmRemotePackageByName_ArgumentGuards, TestNpmRemoteVersionStore_NpmRemoteVersionByPackageAndVersion_ArgumentGuards, TestNewNpmRemotePackageStore_NilClientPanics, TestNewNpmRemoteVersionStore_NilClientPanics |
| Parent repository soft-deleted | Gated upstream by FindRemoteByNameInNamespace (part 1), the only finder producing an npmRemoteRepositoryID; recorded in both doc comments, with the caller obligation not to synthesize the id |
Part 1: …ErrNotFound when the parent repository is soft-deleted |
HTTP error envelope (405 unsupported, 404, 503 upstream_unavailable, upstream 5xx, Retry-After) |
Not in this MR — owned by parts 3–4 and later steps |
Security considerations
| # | Concern | Tests |
|---|---|---|
| Tenant isolation (ADR-001) | A parent binding id alone is never sufficient to read a cached row from another namespace; namespace_id is the leading predicate on both reads |
does not resolve a package from a different namespace, does not resolve a version from a different namespace |
| Cross-repository cache leakage | A cached packument from one remote repository is never served for another repository in the same namespace | does not return a package from a different repository in the same namespace |
| Credential hygiene, Credentials at rest | Not in this MR — the credential lives on npm_remote_repositories (part 1) and is never read here |
|
| SSRF, cross-origin redirect token stripping, outbound path-segment safety, error-payload hygiene | Not in this MR — this layer issues no upstream request and builds no URL |
🔗 References
- Plan: https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/plans/2026-07-15-npm-remote.md — Step 5
- Spec: https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/specs/S15-npm-remote.md
- Superseded MR: !1050 (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), 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 each row's details for the seed shape, rendered SQL, bound args,
and raw plan.
Plans, timings and row widths were captured before eedca285 narrowed
both SELECT lists to the single id column each resolution carries.
The WHERE clauses are byte-identical, so partition pruning and index
choice are unaffected — id is not a key column of either partial
unique index, so no index-only scan becomes available — and dropping
projected columns can only reduce width and cost. Treat the recorded
numbers as an upper bound rather than a different plan shape. The
rendered SQL in each row's details is the current builder output.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.NpmRemotePackageStore.NpmRemotePackageByName |
Limit → Index Scan | npm_remote_packages_p54_namespace_id_npm_remote_repository__idx |
1 / 1 | 8.30 | 0.011ms | 3 / 0 | 1 of 64 |
datastore.NpmRemoteVersionStore.NpmRemoteVersionByPackageAndVersion |
Limit → Index Scan | npm_remote_versions_p02_namespace_id_npm_remote_package_id__idx |
1 / 1 | 8.30 | 0.011ms | 3 / 0 | 1 of 64 |
datastore.NpmRemotePackageStore.NpmRemotePackageByName
Summary: The plan matches the method's intent. The driving scan is an Index Scan over the partition-local child of the partial unique index unique_npm_remote_packages_ns_id_repo_id_name — (namespace_id, npm_remote_repository_id, name) WHERE soft_deleted_at IS NULL — whose Index Cond absorbs all three equality predicates while the index's own partial predicate absorbs the soft_deleted_at IS NULL gate, so the plan carries no Filter and no Sort and the LIMIT never has to discard a row. Passing namespace_id prunes to exactly one of the table's 64 hash partitions (npm_remote_packages_p54), the estimate matches reality exactly (1 / 1), and all 3 execution-time buffer accesses are cache hits with no reads. No anomalies against the flagged criteria, with the same caveat recorded in part 1 of this stack: planning dominates execution by 32x (0.670ms against 0.021ms) and planning touches shared hit=279 opening 64 partitions' catalog entries before pruning discards 63 of them, so the realistic per-request cost is the 0.691ms total rather than the 0.011ms in the Time column above. That is still well inside the 100 ms budget and warrants no code change.
Seed shape: namespaces=1, repositories=1, npm_remote_repositories=1, npm_remote_packages=5000
Rendered SQL:
SELECT npm_remote_packages.id AS "npm_remote_packages.id"
FROM public.npm_remote_packages
WHERE (((npm_remote_packages.namespace_id = $1::uuid) AND (npm_remote_packages.npm_remote_repository_id = $2::uuid)) AND (npm_remote_packages.name = $3::text)) AND (npm_remote_packages.soft_deleted_at IS NULL)
LIMIT $4;Bound args: ['ffc3675e-a4cd-4123-92d3-112690b3ba00', '488c3743-24a1-4262-b1e6-996f8837d7c0', '@review-prep/pkg-002500', 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.30 rows=1 width=101) (actual time=0.010..0.011 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_remote_packages_p54_namespace_id_npm_remote_repository__idx on npm_remote_packages_p54 npm_remote_packages (cost=0.28..8.30 rows=1 width=101) (actual time=0.010..0.010 rows=1 loops=1)
Index Cond: ((namespace_id = 'ffc3675e-a4cd-4123-92d3-112690b3ba00'::uuid) AND (npm_remote_repository_id = '488c3743-24a1-4262-b1e6-996f8837d7c0'::uuid) AND (name = '@review-prep/pkg-002500'::text))
Buffers: shared hit=3
Planning:
Buffers: shared hit=279
Planning Time: 0.670 ms
Execution Time: 0.021 msTimings: planning 0.670ms, execution 0.021ms, total 0.691ms.
datastore.NpmRemoteVersionStore.NpmRemoteVersionByPackageAndVersion
Summary: The plan matches the method's intent and is the same shape as its package-resolver sibling above. The driving scan is an Index Scan over the partition-local child of the partial unique index unique_npm_remote_versions_ns_id_pkg_id_version — (namespace_id, npm_remote_package_id, version) WHERE soft_deleted_at IS NULL — with all three equality predicates in the Index Cond and the soft-delete gate satisfied by the index's partial predicate, leaving no Filter and no Sort; notably the planner picks this partial unique index over the two other candidates rooted at the same leading columns (index_npm_remote_versions_on_ns_id_pkg_id and the size_bytes DESC variant), which is the correct choice because only the unique one is selective on version. The namespace_id bind prunes to one of 64 hash partitions (npm_remote_versions_p02), estimates match reality exactly (1 / 1), and all 3 execution-time buffers are hits with no reads. No anomalies against the flagged criteria; as above, planning dominates execution (0.788ms against 0.019ms, shared hit=300 while opening all 64 partitions before pruning), making 0.807ms the realistic per-request cost and still leaving ample headroom.
Seed shape: namespaces=1, repositories=1, npm_remote_repositories=1, npm_remote_packages=1, npm_remote_versions=5000
Rendered SQL:
SELECT npm_remote_versions.id AS "npm_remote_versions.id"
FROM public.npm_remote_versions
WHERE (((npm_remote_versions.namespace_id = $1::uuid) AND (npm_remote_versions.npm_remote_package_id = $2::uuid)) AND (npm_remote_versions.version = $3::text)) AND (npm_remote_versions.soft_deleted_at IS NULL)
LIMIT $4;Bound args: ['390f85c6-3975-4c1d-a1a8-338805d2c431', '81ae5111-c103-49c4-8ee5-8eb567a58121', '1.0.2500', 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.30 rows=1 width=88) (actual time=0.011..0.011 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_remote_versions_p02_namespace_id_npm_remote_package_id__idx on npm_remote_versions_p02 npm_remote_versions (cost=0.28..8.30 rows=1 width=88) (actual time=0.010..0.010 rows=1 loops=1)
Index Cond: ((namespace_id = '390f85c6-3975-4c1d-a1a8-338805d2c431'::uuid) AND (npm_remote_package_id = '81ae5111-c103-49c4-8ee5-8eb567a58121'::uuid) AND (version = '1.0.2500'::text))
Buffers: shared hit=3
Planning:
Buffers: shared hit=300
Planning Time: 0.788 ms
Execution Time: 0.019 msTimings: planning 0.788ms, execution 0.019ms, total 0.807ms.
Related to #342 (closed)