feat(datastore): batched virtual remote cache reads (S31 plan: 6/19)
Batched remote cache reads for virtual npm positions
Part 1 of 2 of S31 Step 6. A virtual npm read walks its upstream positions in
order, and asking each remote position for its own cache verdict costs one
statement per position. This part adds the two batched reads that answer every
position in a single statement; part 2 adds the seams internal/virtual
consumes them through.
Stacked merge requests
Both parts are row 6 of the S31 plan Status table. One row shipping as several merge requests is this plan's own recorded precedent: it notes that S15's Step 5 shipped as four MRs and Step 9 as five, with the Status row naming each.
| # | Merge request | Scope | Reviewable LOC | Depends on |
|---|---|---|---|---|
| 1 | This MR | internal/datastore, the two batched cache reads |
1586 | main |
| 2 | !1977 | internal/format/npm, the Phase 1 adapter and Phase 2 prober |
896 | This MR |
Merge this one first. Part 2's adapter maps datastore.NpmVirtualRemoteLookup,
which this MR introduces, so part 2 does not compile without it.
!1977 needs a rebase before it can merge. This branch was rebased onto
main while addressing review, so the commit !1977 branched from no longer
exists. !1977 re-stacks with git rebase --onto this branch's new head, not
with a plain rebase onto main, which would replay this MR's commits into it.
What this adds
MetadataByPositions reads npm_remote_metadata_files for a packument or
dist-tags request; TarballByPositions reads npm_remote_files for a tarball.
Both key their verdicts by npm_remote_repositories.id and report a miss by
absence, so a Miss and a Hit carrying no ETag stay distinguishable.
The metadata read filters kind per row. S15's remote route already writes
abbreviated (kind=2) rows, so a read that ignored the column would answer an
abbreviated Accept from a fresh full-kind row.
Freshness is decided in SQL against the database clock, per position, from that
position's own window column. That is what lets one statement serve
repositories configured differently. freshWithinColumnWindowExpr joins
freshWithinHoursExpr in npm_remote_freshness.go, and the two now project the
fresh alias through the shared freshProjectionAlias constant instead of
spelling it inline. Scan destinations still spell it as a struct-tag literal,
which no constant reaches. Only the tarball read carries the zero-window pin
arm, because cache_validity_hours >= 0 by CHECK makes zero reachable there
while metadata_cache_validity_hours > 0 puts it out of reach for the other.
The two query names head their own npm_virtual_remote_* group. The plan
reserves the bare npm_virtual_* prefix for Step 4's reads over the real
virtual tables, and !1974 (merged) and !1975 (merged) each carry a comment reserving it, so a
group named for the read keeps all three consistent. The group comment names
every table each statement spans, per the rule this MR adds to
database-query-patterns.md.
Two things this deliberately does not do
It does not bound the position list. Bounding it belongs to the read that
produces the list, tracked in #885. ADR-004's 20-upstream cap is a separate
rule: the ADR enforces it at write time with a 422, and no code on main
enforces it yet. The file preamble states the two separately rather than
attributing the bound to the cap.
It does not gate the parent repositories row, and it does not check
membership. Membership of the virtual repository, liveness, format and kind
are the producing read's to check, which the store doc now states with
membership first: nothing in either statement checks that a position is
actually an upstream of the repository being served. SoftDelete stamps only
the parent and leaves cache rows live for the reaper, so this is a real
obligation rather than a theoretical one, and it is named where a reader will
look instead of being left to inference.
Testing
Verdicts in every freshness state; both packument kinds in both directions plus dist-tags; per-position windows including the pin; a tombstone at each of the three levels; cross-namespace and cross-package scoping; a strict subset of the seeded positions; the re-cached steady state where an active row shares its key with an evicted one; one statement at 2 and at 20 positions; single-partition plans across all three and all four joined tables; and, per read, that a failed statement returns an error rather than an empty verdict map, so an outage cannot read as a cold cache.
The two scoping cases were verified by deletion rather than by inspection:
removing npm_remote_packages.name fails exactly the two package-scope subtests,
and removing the position IN list fails exactly the two position-scope subtests.
Before those cases existed, either predicate could be deleted with the whole
suite still green.
No e2e scenario is added: docs/testing/e2e/npm.md already lists virtual npm
repositories as owned by S31 and out of scope until the capability ships, and
Step 19 owns the catalog change. No conformance run applies, because this MR
registers no route and implements no npm protocol behaviour.
Size
1586 reviewable LOC, over the 500 in development-model.md, split as source 340 / tests 1167 / docs 79.
Splitting further was considered and rejected. The seam available inside this part is metadata read against tarball read, which divides one Go file, the fixed-statement-count suite, the plan-shape suite, and the query-name group across two merge requests, and forces the freshness documentation to be written once and rewritten in the next one. The two reads share their argument guards, their package-level predicate, their scan shape and their fixtures, so reviewing either alone means reading the other anyway.
Related to #887