Documentation

Architecture

Start with the concrete case: a CI job runs uv pip install pandas against a velodex that has never seen pandas. uv asks for the pandas index page, then for a handful of .metadata files, then for the wheels it chose. velodex has none of it. The naive proxy would download each thing, store it, and then serve it, adding a full download of buffering delay on top of each upstream fetch. Most of this page explains how velodex avoids that: the client receives bytes at upstream wire speed on a cold cache, and from local disk and memory afterwards.

velodex is a single async Rust process built on axum and tokio. A request travels through three layers:

  1. Routing. The HTTP layer validates configured index routes at startup, then resolves each request path by longest prefix. Routes are configuration data rather than compiled-in paths, and each request avoids decoding or normalizing route text. A route also carries its index's ecosystem (pypi today; OCI and npm planned), which selects the format driver — the wire protocol and artifact rules for that packaging format — at the routing boundary. Dispatch is a static match, not a runtime lookup, so an ecosystem a request does not use costs it nothing.
  2. Resolution. The cache layer answers from local state when it can and talks to upstreams when it must. A virtual index walks its layers in order and merges their answers.
  3. Storage. Two stores under one data directory hold all state.

velodex, one process

only on miss

pip / uv / twine

route resolver

cache layer

hot page cache (RAM)

metadata store (redb)

artifact store (disk)

pypi.org, or any upstream

velodex, one process

only on miss

pip / uv / twine

route resolver

cache layer

hot page cache (RAM)

metadata store (redb)

artifact store (disk)

pypi.org, or any upstream

How a page is served

A simple-index page (say /root/pypi/simple/pandas/) can be answered three ways, tried in order of cost:

  1. Hot: the transformed page sits in an in-memory cache, keyed by a mutation epoch so any upload or override invalidates it instantly. Serving is a lookup and a memcpy.
  2. Warm: the raw upstream page sits in the metadata store and is still within its freshness window. velodex transforms it for the requesting route in one in-memory pass (file URLs rewritten, hosted uploads injected, yanked and hidden files applied) and remembers the result in the hot cache.
  3. Cold: nothing usable is stored. velodex opens the upstream request and streams.

hit

miss

yes

no

GET simple/pandas/

hot cache?

serve from RAM

raw page fresh?

transform in memory

stream from upstream

serve while caching

hit

miss

yes

no

GET simple/pandas/

hot cache?

serve from RAM

raw page fresh?

transform in memory

stream from upstream

serve while caching

The stored form is always the raw upstream document (HTML upstreams are canonicalized to PEP 691 JSON once, at fetch time). Transformation happens per request. That ordering matters for virtual indexes: one cached pypi.org page can serve any number of routes that layer it, each with different hosted files shadowing it, without storing a variant per route.

How bytes reach the client before they reach the disk

The cold path is where proxies lose their users. velodex never buffers a whole response to work on it; both pages and artifacts stream, with the caching work riding along:

upstreamvelodexuvupstreamvelodexuvraw page persists before thefinal chunk, so file lookupsthat follow always resolveafter the client has everything:verify sha256, rename into the storeGET simple/pandas/GET (If-None-Match)200, JSON streamstransformed JSON, chunk by chunkGET files/{sha256}/pandas…whlGET wheelbytesthe same bytes, teed to a temp file
upstreamvelodexuvupstreamvelodexuvraw page persists before thefinal chunk, so file lookupsthat follow always resolveafter the client has everything:verify sha256, rename into the storeGET simple/pandas/GET (If-None-Match)200, JSON streamstransformed JSON, chunk by chunkGET files/{sha256}/pandas…whlGET wheelbytesthe same bytes, teed to a temp file

For pages, a chunk-at-a-time transformer rewrites each files[] element mid-flight (URL rewriting, hosted-file injection, yank and hide overrides), so the client starts parsing while the upstream transfer is still running. For artifacts, the tee hashes into a temp file that is verified and atomically renamed into the store after the client already has its bytes. A digest mismatch still forwards (pip and uv verify hashes themselves) but is never cached, and shows up as rejected in the usage counters.

File URLs put the sha256 in the path because it is the real storage key. The filename is kept for installer behavior, browser save names, and operator logs, but velodex treats it as one percent-encoded path segment and rejects decoded separators, traversal, and control characters. Archive inspection uses the same rule for the distribution filename and passes member paths in a query parameter so member names can contain / without becoming route structure. The inspector opens cached blobs from disk and returns member text by byte offset, so looking at a large generated file does not require loading the whole archive member into server memory or the browser.

Nested ZIP inspection keeps the same constraint. Velodex reads stored ZIP members as seekable slices of the cached blob; compressed nested archives stream into bounded temporary files because their inner directory cannot be addressed without decompression. Listing and preview endpoints cap nesting depth, entry count, nested archive size, and returned text bytes.

Three more decisions keep the cold path at wire speed:

  • Single-flight. Resolvers fire many requests for the same project concurrently. Concurrent misses for one page or file share one upstream fetch; the rest wait for the first and serve from its result.
  • Nothing durable blocks the response. Page records commit to redb without an fsync (losing a cache entry in a crash costs a refetch, nothing more), and artifact verification runs after the client's last byte.
  • HTTP/1.1 for artifact downloads. HTTP/2 would multiplex every concurrent wheel over one TCP connection and its single congestion window; one connection per artifact keeps large parallel downloads at full bandwidth.

When does cached content expire?

Artifacts never do. They are addressed by sha256, so "a new version of the file" is by definition a different file with a different address; anything in the store is correct forever.

Pages do. Each cached page carries the freshness lifetime its upstream granted via Cache-Control (s-maxage over max-age; pypi.org grants 600 seconds). When the server grants none (the header is absent, no-cache, no-store, or zero), the configured cache_ttl_secs fallback of 300 seconds applies.

lifetime lapses

304, nothing changed

200, new content

upstream down

upstream back

First fetch

Fresh

Stale

ServedStale

lifetime lapses

304, nothing changed

200, new content

upstream down

upstream back

First fetch

Fresh

Stale

ServedStale

A stale page is not dropped: the next request revalidates it with If-None-Match, and the common answer is a 304 with no body, which just resets the clock. A background sweep revalidates every stale page once a minute, so an upstream change lands within about one freshness window even when nobody requests the page; each detected change is logged and counted. When the upstream errors or is unreachable, the stale copy serves, and a pypi.org outage degrades to stale-but-working rather than red builds.

The metadata store

Project pages, file-to-URL mappings, uploads, and the change serial live in redb, an embedded, crash-safe, copy-on-write B-tree in pure Rust. redb gives one writer and many concurrent readers with snapshot isolation, which fits an index server's read-heavy traffic without an external database. Page records use a framed encoding (a small JSON header line, then the raw body bytes), so a multi-megabyte page is not re-encoded as JSON numbers and header-only scans (the freshness sweep) skip the body.

The cache CLI uses the same store boundaries. Listing and size reporting walk redb tables row by row and summarize framed page records without copying page bodies. Project purge deletes one page row, the project-display row, and only the file URL or PEP 658 rows whose digests no other cached page or upload references. Velodex checks shared digest references before deletion, so a purge for one project does not break another project that shares a file digest.

The artifact store

Artifacts live in a content-addressed store keyed by sha256, fanned out two hex levels deep (sha256/ab/cd/<digest>). Writes go to a temp file, fsync, then an atomic rename, so a crash cannot leave a partial blob visible; the path is the digest, so anything present is by construction correct. One wheel uploaded to two indexes, or cached from two upstreams, occupies disk once.

Cache validation streams each blob through sha256 and compares the result to the digest in the path. Orphaned-blob purge first builds a set of digest references from metadata rows, then walks the blob tree one file at a time. It reads blob contents only when cache fsck asks for hash validation.

Uploads use the same staged path as downloads: the multipart content field streams into a temp blob while sha256 and blake2b-256 are computed. Validation reads the archive back from that staged file, so a large wheel is not buffered in the HTTP handler. Wheel validation scans the ZIP directory, buffers capped METADATA, WHEEL, and RECORD files, and streams members through the RECORD hash checks instead of loading wheel payloads into memory.

Sdist validation uses the same pattern. velodex streams the .tar.gz entries, rejects unsafe paths, unsafe links, and special files, and buffers only capped PKG-INFO content. Metadata 2.4+ License-File entries are checked against the member names seen during the scan; the archive is not unpacked.

Why PEP 658 matters here

Resolvers spend most of their network time learning dependencies. The PEP 658/714 .metadata sibling lets pip and uv fetch a few kilobytes of core metadata instead of a multi-megabyte artifact per candidate. velodex uses an advertised upstream sibling first, verifies it against the digest from the index page, and caches it like any blob. When the upstream page lacks that sibling, velodex reads a wheel's ZIP central directory with HTTP byte ranges, fetches only the METADATA member, and records the generated sibling for later page responses. If an index does not satisfy range requests, velodex remembers that for the process and streams the artifact into the blob store before extracting metadata from the cached file. Sdist backfill uses the same cached-file path and buffers only capped PKG-INFO content. For hosted uploads, velodex writes the sibling from verified wheel METADATA or sdist PKG-INFO. The per-index velodex_index_metadata_total metric counts these; the end-to-end tests assert on it to prove real clients take this path. Few third-party indexes serve PEP 658 yet, so fronting one with velodex can make resolution faster than the upstream itself once metadata is cached.

Usage metrics

Handlers record events (page served, file downloaded, upload accepted, refresh outcome) with one non-blocking channel send; a dedicated thread aggregates them into an index → project → file tree. The request path never takes the aggregation lock; recording costs one channel send. The tree serves /+stats, the dashboard's usage drill-down, and the per-index Prometheus counters.

Distribution

velodex ships one static binary through two channels. GitHub releases carry per-platform archives and installer scripts (built by dist); these copies carry the self-update feature and an install receipt, so velodex self update can replace them in place. PyPI carries the same binary wrapped in a bindings = "bin" wheel: Python-shop operators get velodex through the tooling they already run (uv tool install, a requirements.txt line, an internal mirror) without a second artifact channel, and since no Python ABI is involved, one wheel per platform serves every interpreter. Wheel installs have no self-update: pip owns that file, and the updater refuses copies without a receipt rather than fight it.

The web UI

The UI is a Leptos application compiled twice from one codebase: natively into the server, which renders every page to HTML, and to WebAssembly (by cargo-leptos), which hydrates the page in the browser for reactivity: live counters, filter-as-you-type, and the upload-management buttons. Pages work without the bundle, so the server never depends on a wasm toolchain.

This split also decides how the UI is tested. The server half is ordinary Rust: velodex's test suite renders each page through the real router and asserts on the HTML. The browser half cannot feed the coverage gate, because wasm32-unknown-unknown has no coverage instrumentation and event handlers only execute in a browser; a Playwright suite drives the hydrated UI instead (search, package pages, the archive browser, and token-authenticated yank and delete), which is the stronger check for interactive behavior anyway.

The UI reads velodex's own public API: /+status for the dashboard, /+status?details=admin for the admin status page, /+stats for usage, the PEP 691 simple endpoints for package data, and the inspect endpoints for archive contents. The admin status document summarizes metadata keys for observed project counts, uploaded file counts, and capped recent uploads; it does not fetch upstreams or read cached artifact bytes. Anything the UI shows, a script can fetch the same way.

Tradeoffs

  • One process, local state. No replication, no failover. A cache instance per site or cluster is the intended shape; each warms independently.
  • The first request for anything pays upstream latency. Streaming removes the buffering penalty, not the network. A cold cache behaves like pypi.org plus one hop until it has seen your working set once.
  • redb has one writer. Fine for an index server (reads dominate by orders of magnitude), wrong for a write-heavy workload.
  • Trust follows the hash. velodex verifies artifacts against the digests the index page advertises. If an upstream lies about its own hashes, velodex caches the lie; it defends the transport, not the source.

In practice

On this page