From 8f30c6d47808ffe19a78ee8747c500fbe5a29b6b Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 11 Oct 2026 01:38:14 +0200 Subject: [PATCH] Enable storage retention for julia Julia takes no package rules, since its PURLs need a uuid qualifier the cache doesn't store; configuring one already fails validation. The julia rule and the default cover package tarballs, artifacts and registry snapshots alike. Everything there is content-addressed, so an evicted tarball comes back unchanged. Refs #306 --- docs/configuration.md | 2 +- internal/handler/julia.go | 9 ++ internal/server/retention_julia_test.go | 186 ++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 internal/server/retention_julia_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 41ba4d0d..43a3ab89 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,7 +99,7 @@ A package rule wins over an ecosystem rule, which wins over `default`. Package r | `golang` | yes | `pkg:golang/github.com/stretchr/testify`, the module path as in `go.mod`; case doesn't matter. A major version like `/v2` (or `.v3` on gopkg.in) is a module of its own and needs its own rule. The `!`-escaped form from proxy URLs and the module cache (`github.com/!azure/...`) is refused. | | `helm` | no | - | | `hex` | no | - | -| `julia` | no | - | +| `julia` | yes | No package rules: a julia PURL needs a `uuid` qualifier, which the cache doesn't store. The `julia` rule and `default` cover package tarballs, artifacts and registry snapshots alike. | | `maven` | yes | `pkg:maven/com.google.guava/guava`, group and artifact as published, case included. Gradle build cache entries are not covered; they use `gradle.build_cache.max_age`. | | `npm` | yes | `pkg:npm/lodash`, `pkg:npm/@babel/core`; the name has to match exactly, case included, so `pkg:npm/jsonstream` doesn't cover `JSONStream`. | | `nuget` | no | - | diff --git a/internal/handler/julia.go b/internal/handler/julia.go index 66a22459..749335b9 100644 --- a/internal/handler/julia.go +++ b/internal/handler/julia.go @@ -14,6 +14,7 @@ import ( "sync" "github.com/BurntSushi/toml" + "github.com/git-pkgs/proxy/internal/retention" ) const ( @@ -28,6 +29,14 @@ var ( juliaUUIDPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) ) +// Julia takes no package rules: its PURLs need a uuid qualifier the cache +// doesn't store. The julia rule and the default cover package tarballs, +// artifacts and registry snapshots alike; all are content-addressed, so an +// evicted one comes back unchanged. +func init() { + retention.Default.Register(retention.Spec{Key: "julia"}) +} + // JuliaHandler handles Julia Pkg server protocol requests. // // See https://pkgdocs.julialang.org/v1/registries/ and the PkgServer.jl diff --git a/internal/server/retention_julia_test.go b/internal/server/retention_julia_test.go new file mode 100644 index 00000000..5577ec73 --- /dev/null +++ b/internal/server/retention_julia_test.go @@ -0,0 +1,186 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/proxy/internal/database" + "github.com/git-pkgs/proxy/internal/handler" + "github.com/git-pkgs/proxy/internal/retention" + "github.com/git-pkgs/registries/fetch" +) + +const ( + juliaPackageUUID = "7876af07-990d-54b4-ab0e-23690620f79a" + juliaPackageHash = "46e44e2b1ab5b2e0b7a2b0b63c87a4b0a3f6b2a1" + juliaArtifactHsh = "0c61b4f2e0a4b0c3e8f1a2b3c4d5e6f708192a3b" + juliaRegistryUID = "23338594-aafe-5451-b93e-139f81909106" + juliaRegistryHsh = "9a1f0e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7" +) + +// juliaRetentionUpstream serves a package tarball, an artifact and a +// registry snapshot the way a Pkg server does, counting downloads. The +// snapshot isn't a real registry, so the handler can't load names and files +// the package under its uuid. +func juliaRetentionUpstream(t *testing.T) (*httptest.Server, *atomic.Int64) { + t.Helper() + var downloads atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/package/" + juliaPackageUUID + "/" + juliaPackageHash, "/artifact/" + juliaArtifactHsh, + "/registry/" + juliaRegistryUID + "/" + juliaRegistryHsh: + downloads.Add(1) + _, _ = io.WriteString(w, "tarball bytes") + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, &downloads +} + +func newJuliaRetentionProxy(t *testing.T, upstream *httptest.Server) (*httptest.Server, *database.DB) { + t.Helper() + db, store := setupEvictionTest(t) + // The upstream is a plain-HTTP loopback server; the default fetcher + // refuses loopback, a stock client reaches it. + fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0)) + t.Cleanup(func() { _ = fetcher.Close() }) + proxy := handler.NewProxy(db, store, fetcher, fetch.NewResolver(), discardLogger()) + proxy.HTTPClient = upstream.Client() + h := handler.NewJuliaHandlerWithUpstream(proxy, upstream.URL) + srv := httptest.NewServer(h.Routes()) + t.Cleanup(srv.Close) + return srv, db +} + +func juliaGet(t *testing.T, url string) { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s = %d %s, want 200", url, resp.StatusCode, body) + } +} + +// juliaArtifact returns the record of the tarball named after hash. +func juliaArtifact(t *testing.T, db *database.DB, hash string) database.Artifact { + t.Helper() + var art database.Artifact + if err := db.Get(&art, db.Rebind(`SELECT * FROM artifacts WHERE filename = ?`), hash+".tar.gz"); err != nil { + t.Fatalf("reading artifact %s: %v", hash, err) + } + return art +} + +func juliaBackdate(t *testing.T, db *database.DB, hash string, fetched, accessed time.Time) { + t.Helper() + query := db.Rebind(`UPDATE artifacts SET fetched_at = ?, last_accessed_at = ? WHERE filename = ?`) + if _, err := db.Exec(query, fetched, accessed, hash+".tar.gz"); err != nil { + t.Fatalf("backdating %s: %v", hash, err) + } +} + +func TestJuliaRegistersForRetention(t *testing.T) { + spec, ok := retention.Default.Lookup("julia") + if !ok { + t.Fatal("julia is not registered for retention") + } + if spec.CanonicalPackage != nil { + t.Error("julia accepts package rules, but its PURLs can never match a stored package") + } + if key, ok := retention.Default.KeyForDBEcosystem("julia"); !ok || key != "julia" { + t.Errorf("KeyForDBEcosystem(julia) = %q, %v, want julia, true", key, ok) + } +} + +// An artifact served from the cache stays while it is used, is evicted once +// its rule runs out, comes back from upstream on the next download, and +// stays after that even with the evicted copy's old access time on the +// record. +func TestJuliaRetentionEvictsAndRefetches(t *testing.T) { + upstream, downloads := juliaRetentionUpstream(t) + srv, db := newJuliaRetentionProxy(t, upstream) + artifactURL := srv.URL + "/artifact/" + juliaArtifactHsh + + juliaGet(t, artifactURL) + juliaGet(t, artifactURL) + if got := downloads.Load(); got != 1 { + t.Fatalf("upstream downloads = %d, want 1", got) + } + if !juliaArtifact(t, db, juliaArtifactHsh).LastAccessedAt.Valid { + t.Fatal("cache hit did not record an access") + } + + rules := retention.Rules{Ecosystems: map[string]time.Duration{"julia": retentionDay}} + now := time.Now() + sweepRetention(context.Background(), db, discardLogger(), retention.Default, rules, now) + if !juliaArtifact(t, db, juliaArtifactHsh).StoragePath.Valid { + t.Fatal("artifact used today was evicted") + } + + old := now.Add(-2 * retentionDay) + juliaBackdate(t, db, juliaArtifactHsh, old, old) + sweepRetention(context.Background(), db, discardLogger(), retention.Default, rules, now) + if juliaArtifact(t, db, juliaArtifactHsh).StoragePath.Valid { + t.Fatal("artifact unused for two days kept under a one-day rule") + } + + juliaGet(t, artifactURL) + if got := downloads.Load(); got != 2 { + t.Errorf("upstream downloads = %d, want 2 after the eviction", got) + } + art := juliaArtifact(t, db, juliaArtifactHsh) + juliaBackdate(t, db, juliaArtifactHsh, art.FetchedAt.Time, now.Add(-30*retentionDay)) + sweepRetention(context.Background(), db, discardLogger(), retention.Default, rules, time.Now()) + if !juliaArtifact(t, db, juliaArtifactHsh).StoragePath.Valid { + t.Error("refetched artifact evicted again by the next sweep") + } +} + +// Package rules for julia are refused, while the julia rule and the default +// both reach package tarballs, artifacts and registry snapshots. +func TestJuliaRetentionEcosystemRuleAndDefault(t *testing.T) { + cfg := config.Default() + cfg.Storage.Retention.Packages = map[string]string{"pkg:julia/Example?uuid=" + juliaPackageUUID: "0"} + if _, err := cfg.RetentionRules(retention.Default); err == nil || !strings.Contains(err.Error(), "not supported for julia") { + t.Errorf("RetentionRules(julia package) error = %v, want package rules refused", err) + } + + upstream, _ := juliaRetentionUpstream(t) + srv, db := newJuliaRetentionProxy(t, upstream) + for _, rules := range []retention.Rules{ + {Ecosystems: map[string]time.Duration{"julia": retentionDay}}, + {Default: retentionDay}, + } { + juliaGet(t, srv.URL+"/package/"+juliaPackageUUID+"/"+juliaPackageHash) + juliaGet(t, srv.URL+"/artifact/"+juliaArtifactHsh) + juliaGet(t, srv.URL+"/registry/"+juliaRegistryUID+"/"+juliaRegistryHsh) + old := time.Now().Add(-30 * retentionDay) + juliaBackdate(t, db, juliaPackageHash, old, old) + juliaBackdate(t, db, juliaArtifactHsh, old, old) + juliaBackdate(t, db, juliaRegistryHsh, old, old) + sweepRetention(context.Background(), db, discardLogger(), retention.Default, rules, time.Now()) + + if juliaArtifact(t, db, juliaPackageHash).StoragePath.Valid { + t.Errorf("with %+v: package tarball kept", rules) + } + if juliaArtifact(t, db, juliaArtifactHsh).StoragePath.Valid { + t.Errorf("with %+v: artifact kept", rules) + } + if juliaArtifact(t, db, juliaRegistryHsh).StoragePath.Valid { + t.Errorf("with %+v: registry snapshot kept", rules) + } + } +}