Skip to content

feat(index): RSFIndex, a MetadataIndex over a local RSF file - #5

Merged
jonyoder merged 1 commit into
mainfrom
rsfindex
Aug 4, 2026
Merged

feat(index): RSFIndex, a MetadataIndex over a local RSF file#5
jonyoder merged 1 commit into
mainfrom
rsfindex

Conversation

@jonyoder

@jonyoder jonyoder commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

What

RSFIndex — completes the standalone metadata path. Give it an RSF file and it answers Versions and Metadata with no network and no database.

Validated end to end on the real production corpus

The unit tests use synthetic fixtures; this is the part that actually matters. Walking outward from flask through the whole layer:

walked 131 packages to depth 3 via 260 index calls in 34.9ms

with zero unresolvable names — every dependency name parsed out of one package's metadata resolved to a real package in the index.

That's the genuine precondition for a solver, and fixtures can't establish it. It proves requirements parse, names normalize consistently on both the producing and consuming side, and lookups compose transitively on real data. Also a useful figure against #18651's <100 ms cold gate: the metadata layer is nowhere near the budget.

Files returns a new sentinel

An RSF carries no filename, hash, upload time, or yanked flag, so Files always returns ErrFilesUnavailable.

Distinct from ErrMetadataUnavailable on purpose, because the two call for different handling: this one means "ask another source", the other means "choose another version". Returning an empty slice would assert something false — that the version ships no files.

Two deliberate asymmetries in handling bad producer data

Both tested, because the reasoning is what makes them right rather than arbitrary:

  • An unparseable requirement fails the lookup. Skipping it would hand the resolver an incomplete dependency set and produce a confidently wrong answer — the one failure mode worth being loud about.
  • An unparseable requires_python leaves the constraint unset. That over-admits a candidate and surfaces as an install-time failure, whereas dropping a requirement would silently change the resolution itself.

Same reasoning elsewhere: a version key PEP 440 rejects is skipped rather than failing the package, so one bad key can't hide every other version. And a version absent from the deps blob reports ErrMetadataUnavailable, not ErrPackageNotFound — the package is genuinely present, and not-found would invite a resolver to treat it as a typo and give up.

Other behavior

  • Extras are PEP 685-normalized on the way out, so pkg[Test-Suite] matches a declared test_suite.
  • Version lookup falls back to PEP 440 equality when the producer's key spelling differs from the request (1.0 vs 1.0.0).
  • Decoded deps are cached per package, because a resolver calls Versions once then Metadata per candidate; without it every call would re-read and re-decompress the same blob. The cache is deliberately unbounded: a resolution touches its closure, so it's bounded by requested work rather than by the corpus. A long-lived server resolving arbitrary requests would want a bound — that isn't this consumer, and the doc comment says so. Copy-on-return plus a -race test, since the cached map is shared behind the interface's concurrency promise.

Testing

  • 97.7% statement coverage on index/; -race -count=1 clean; gofmt -l . silent; golangci-lint run ./... at CI's pinned v2.11.2 → 0 issues from the module root.
  • TestRSFIndexAgainstRealFile is opt-in via PYPIRSF_TEST_FILE.
  • Fixtures are written with the real RSF writer over pypirsf's layout structs, so framing comes from the code that produces production files.

Also rewrote index/doc.go, which had reverted to its pre-#18647 text when the CachedJSONIndex PR was closed and so still described implementations that aren't coming.

Next

A thin CLI, so there's a runnable artifact. Worth being explicit that actual resolution is gated on go-pubgrub, which does not exist yet (Phase 4, #18653) — everything so far is the metadata layer it will sit on.

🤖 Generated with Claude Code

Completes the standalone metadata path: give it an RSF and it answers
Versions and Metadata with no network and no database.

VALIDATED END TO END ON THE REAL PRODUCTION CORPUS. Walking outward from
flask, the layer reached 131 packages at depth 3 via 260 index calls in
34.9 ms, and every dependency name parsed out of one package's metadata
resolved to a real package in the index -- zero unresolvable names. That is
the actual precondition for a solver, and synthetic fixtures cannot establish
it: it proves requirements parse, names normalize consistently on both the
producing and consuming side, and lookups compose transitively on real data.
Also a useful figure against the #18651 gate (<100 ms cold): the metadata
layer is nowhere near the budget.

Files always returns the new ErrFilesUnavailable. An RSF carries no filename,
hash, upload time, or yanked flag, so there is nothing to report. A distinct
sentinel rather than reusing ErrMetadataUnavailable because the two call for
different handling: this one means "ask another source", the other means
"choose another version". Returning an empty slice would assert something
false -- that the version ships no files.

Two deliberate asymmetries in how bad producer data is handled, both tested:

  * An unparseable REQUIREMENT fails the lookup. Skipping it would hand the
    resolver an incomplete dependency set and produce a confidently wrong
    answer, which is the one failure mode worth being loud about.
  * An unparseable requires_python leaves the constraint unset. That
    over-admits a candidate and surfaces as an install-time failure, whereas
    dropping a requirement would silently change the resolution itself.

Similarly, a version key PEP 440 rejects is skipped rather than failing the
package, so one bad key cannot hide every other version; and a version absent
from the deps blob reports ErrMetadataUnavailable rather than
ErrPackageNotFound, since the package is genuinely present and reporting
not-found would invite a resolver to treat it as a typo.

Extras are PEP 685-normalized on the way out so pkg[Test-Suite] matches a
declared test_suite. Version lookup falls back to PEP 440 equality when the
producer's key spelling differs from the request ("1.0" vs "1.0.0").

Decoded deps are cached per package, because a resolver calls Versions once
and then Metadata per candidate -- without it every call would re-read and
re-decompress the same blob. The cache is deliberately unbounded: a
resolution touches its closure, so it is bounded by requested work rather
than by the corpus. A long-lived server resolving arbitrary requests would
want a bound; that is not this consumer. Copy-on-return, and a -race test,
because the cached map is shared behind the interface's concurrency promise.

Verified: 97.7% statement coverage on index/; -race clean; gofmt silent;
golangci-lint v2.11.2 reports 0 issues from the module root. doc.go rewritten
to match reality -- it had reverted to its pre-#18647 text when the
CachedJSONIndex PR was closed.

Refs rstudio/package-manager#18647

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jonyoder
jonyoder merged commit 1c7d55a into main Aug 4, 2026
2 checks passed
@jonyoder
jonyoder deleted the rsfindex branch August 4, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant