feat(npm): hosted tarball lookup and its Phase 1 upstream adapter (S31 plan: 5/19)
What
Completes plan step 5 on top of the metadata lookups: the third batched
authoritative read, for tarballs, and the remote.Upstream adapter that presents
all three document kinds to the virtual resolver as Phase 1 answers.
Stacked MRs
| Part | MR | Scope | Reviewable LOC | Target |
|---|---|---|---|---|
| 1/2 | !1974 (merged) | the two metadata reads | 1,091 | main |
| 2/2 | this MR | tarball read and the Phase 1 upstream adapter | 1,234 | main |
Merge order is 1 then 2, and it is not optional. This MR's
VirtualHostedLookupStore seam declares one method per document kind, so it
cannot compile before part 1's reads exist. Part 1 has merged, so this MR is
rebased onto main and targets it directly.
Part 1 also carries a deliberate departure from the spec on the dist-tags predicate that needs a ruling. It is described in part 1 and is not re-argued here.
The tarball read
It is the one projecting a column beyond the position id,
npm_files.blob_sha256, because only a tarball hit names a committed blob.
unique_npm_files_ns_id_version_id_file_name is unique per version and not per
package, so nothing in the schema stops one file name sitting under two live
versions of one package and matching twice for one position. The npm publish path
does not write that shape, since it derives the file name from the version, so
DISTINCT ON the position is defence in depth rather than a fix for a row shape
in production today. Ordering by created_at DESC then id DESC resolves a
match to the current tarball rather than to a resurrected predecessor;
npm_files.id is the leading column of the composite primary key, which the
bound namespace_id makes unique here.
Three properties no result set shows
- The
DISTINCT ONkey must be the column theINlist binds, or the bound becomes that column's cardinality, which grows with the table. Asserted on the rendered SQL. - The whole
ORDER BY, whose trailing unique column is what makes the surviving row deterministic between runs. Asserted on the rendered SQL. - The join direction. The read binds
file_namebut no version, so driven fromnpm_packagesit would walk every live version of the package and probenpm_filesonce each, and ADR-004 permits 25,000 of them. Asserted on anEXPLAIN ANALYZEplan, against a fixture seeded with enough decoy versions to make the rejected plan the visibly worse one.
The assertion is the direction rather than the index npm_files is reached by:
which index wins is data-dependent, and on a fixture this size PostgreSQL filters
file_name instead of binding index_npm_files_on_ns_id_file_name.
The adapter
A hosted Hit is always Fresh, because the read tests the authoritative rows and
has no cache to be stale against, so a hosted position never enters Phase 2. Only
a tarball Hit carries a blob reference. An invalid target errors rather than
reading as a Miss; a blob_sha256 of the wrong length is reported rather than
rendered into an address naming no blob; and a hit for a position outside the
requested batch is refused rather than added to the answer. Each position keeps
the npm_repositories.id its answer came from, which remote.Upstream has no
field for, so the resolver can name the position it consulted.
remote.LookupResult.BlobRef may now be empty on a Hit
A hosted upstream generates a metadata document rather than caching one, so this
MR is the first thing in the tree to falsify the field's old promise. The doc now
says the reference is optional without prescribing a discriminator: a remote
(kind=2) metadata Hit does carry a reference, so the requested document kind
alone does not decide, and the winner's upstream kind is what does.
remote.CacheStore.Lookup states the invariant Standalone.openCached turns
into a blob address without a kind check, and internal/virtual's own
restatements of the promise (ResolveServeFromCache, ResolveResult.Cache) are
widened to match, so the contract does not ship half-changed.
Diff size
1,234 reviewable LOC, over the 500 LOC guidance in development-model.md, so here is the split and the reason:
| Group | LOC |
|---|---|
| Production Go | 305 |
| Tests | 872 |
| Docs | 57 |
| Total | 1,234 |
Production code is 305 LOC, inside the ceiling. Step 5 was already split into two MRs of roughly equal size to keep each reviewable, and this part is the smaller half of the production code.
This part deliberately carries two things, the tarball read and the adapter over all three kinds, and that is the accepted trade-off rather than an oversight. The adapter's seam declares one method per document kind, so it cannot compile before the tarball read exists; splitting it out into a third MR would leave a part whose only content is an interface no caller reaches.
Testing
- The tarball read: positive hit carrying the blob, a
Missat every soft-delete level (file, version, package), cross-namespace and unlisted positions, the empty batch, statement failure, and the empty-file-name guard. The read also goes through part 1's shared argument validator, so part 1's zero-position rejection now runs once per document kind, tarballs included. - The
DISTINCT ONcollapse driven by the only row shape that changes the answer: one file name under two live versions, seeded in reverse order so the tiebreaker decides. - The adapter:
Freshalways true on a Hit, a blob reference for tarballs alone, the zero values of both exported types, the nil-store panic, error propagation, a shortblob_sha256refused, an invalid target refused with the sentinelTarget.Validateactually returns, and a hit outside the requested batch refused. - One statement per document kind at 2 and at 20 positions, now including tarballs.
Integration suite run locally against a CI-tuned PostgreSQL; golangci-lint run
with --build-tags=integration, which CI lint cannot see.
e2e catalog
No scenario added or affected. This step adds no route and no handler: nothing reaches this code until the composition root in step 14, and the virtual e2e scenarios are step 19's deliverable.
Closes #886 (closed)