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

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading