Skip to content

Archive backend: fetch repos as tarballs for remote drift runs#2570

Merged
berendt merged 8 commits into
mainfrom
archive-snapshot
Jul 9, 2026
Merged

Archive backend: fetch repos as tarballs for remote drift runs#2570
berendt merged 8 commits into
mainfrom
archive-snapshot

Conversation

@ideaship

@ideaship ideaship commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Speeds up and stabilizes remote drift runs by fetching each repo once as a
tarball instead of per-file over the GitHub API, plus the transport-layer refactor
it builds on.

Archive backend

  • archive.py — fetches a (repo, ref) tarball once, memoizes it, and serves all
    reads/listings from the extracted tree. On by default; --use-raw-get selects the
    old per-file raw-GET path.
  • Turns a full remote run from ~thousands of GitHub requests into one tarball per
    repo — far less rate-limit exposure.

Transport refactor it builds on

  • _get — one shared helper for the repeated GitHub-request skeleton (auth, timeout,
    error wrapping, rate-limit hint).
  • http.py — the transport/error layer extracted out of source.py.
  • Progress output during remote reads; a CDN-429 hint for raw.githubusercontent.com;
    shared dir-tree helpers for local branches.

Behavior-preserving for existing checks; 303 tests pass.

Merge order

This PR carries the shared transport layer (_get, http.py, progress) that the
drift-tooling PR also currently contains. Merge this before drift-tooling; that PR
is then rebased to drop the two now-shared commits.

Related

ideaship added 8 commits July 9, 2026 12:49
Seven remote source functions (read, read_optional, list_tree,
list_dir, list_dir_at_ref, ref_exists, read_at_ref) each repeated the
same GitHub-request skeleton: a requests.get with auth headers and a
30s timeout, a try/except wrapping RequestException into a SourceError,
and a "raise _http_error on non-ok" guard. The only per-site variation
was whether the REST Accept header is sent and which status codes are
treated as a valid outcome rather than a failure (a 404 meaning
"absent", the commits API's 404/422 meaning "ref does not resolve").

Collapse that skeleton into one helper:

    _get(action, url, *, json_api=False, ok=())

json_api toggles the GitHub REST Accept header; ok lists the status
codes the caller handles itself, which _get returns without raising
(everything else non-ok still goes through _http_error and keeps the
rate-limit hint). Each caller now keeps only its genuine difference --
its own branch on the whitelisted 404/422 -- so the transport,
timeout, auth, and error wrapping live in a single place.

Behavior-preserving; no call site changes what it returns or raises.
All 281 tests pass.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Roger Luethi <luethi@osism.tech>
A remote drift run reads every repo through the GitHub API: dozens of
requests spread over several minutes, with no output between the source
listing and the final report. To a user that looks like a hang, and the
common reaction is an impatient Ctrl-C that aborts the run.

Give the wait a shape. source.py gains an opt-in progress sink: when a
sink is installed, each remote read prints one short line naming the
kind of call (raw/tree/list/ref?), the repo and path, and the ref, with
a running counter that doubles as a rough rate-limit gauge. The default
sink is a no-op, so library and test callers stay silent; only the
remote code paths call _note(), so local runs are unaffected.

The driver installs the sink (to stderr) only when the run is not
--quiet and at least one source actually resolves remote, and prints a
one-line "this can take several minutes" heads-up first so the reason
for the wait is stated before it begins. A fully local run prints
neither the warning nor the per-call lines.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Roger Luethi <luethi@osism.tech>
The rate-limit hint added to remote reads only fires when the response
carries the GitHub REST API markers (X-RateLimit-Remaining: 0 or a
Retry-After header). Those come from api.github.com. The bulk of a remote
drift run, however, fetches file bytes from raw.githubusercontent.com --
a Fastly CDN that throttles per-IP and returns 429 with none of those
headers. So the read most likely to be throttled (over a thousand raw
GETs in a full run) was the one that produced a bare "HTTP 429 fetching
<url>" with no explanation, which is what a coworker reported.

Treat a markerless 429 as CDN throttling and emit its own hint: the raw
host is rate-limited per-IP, intermittently, and separately from the
GitHub API budget, so a token does not draw it from the 5000/hr pool and
is no guaranteed fix. The advice points instead at --base-dir (read from
local checkouts and skip the remote fetch entirely), retrying later, or
switching networks.

A markerless 403 is left untouched: that is an ordinary auth/permission
refusal, not throttling, and must stay a plain error -- only a markerless
429 flips to the new hint. The api.github.com path (header-bearing 403 or
429) keeps its existing quota/reset message unchanged.

Verified end-to-end: read() against a header-less 429 now renders the raw
hint, and a header-less 403 still renders a bare HTTP 403.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Roger Luethi <luethi@osism.tech>
Move SourceError, _GITHUB_HOSTS, _auth_headers, _rate_limit_hint,
_http_error, _GH_JSON, and _get from source.py into a new leaf module
http.py. Re-export all seven names from source.py so existing callers
resolve source.SourceError, source._get, etc. without change.

Add a timeout keyword parameter (default 30 s) to _get so the new
archive backend can pass a longer timeout without touching existing
callers.

Full test suite (304 tests) passes unchanged.

Assisted-by: Claude:claude-sonnet-4-6
Signed-off-by: Roger Luethi <luethi@osism.tech>
Introduce _dir_read, _dir_read_optional, _dir_list_tree, and _dir_list
in source.py. These take a where context string used in error messages
so callers control the wording.

Refactor the unpinned-local branches of read, read_optional, list_tree,
and list_dir to call the matching helper, preserving the existing error
messages byte-for-byte (e.g. "not found in local {repo} ({d})").

The pinned-git branches (_git_show / _git_ls_tree) and list_dir_at_ref /
read_at_ref are not touched. Task 5 reuses these helpers for the archive
backend without duplication.

Add tests/kolla_drift/test_dir_helpers.py exercising all four helpers
against real tmp_path trees (no mocking).

Full suite (314 tests) passes unchanged.

Assisted-by: Claude:claude-sonnet-4-6
Signed-off-by: Roger Luethi <luethi@osism.tech>
Introduce src/osism_drift/archive.py, a leaf module that depends only
on http.py. snapshot_dir(owner, repo, ref, config) fetches a GitHub
tarball at the archive endpoint, extracts it to a tmp dir (cleaned up
at exit), and returns the Path to the extracted tree. Results are
memoized on config.snapshot_cache by (github_api, owner, slug, ref)
so repeated reads within one run collapse to one HTTP request.

_fetch_archive_bytes retries once on transport/server errors before
re-raising. _safe_extract uses the data filter (Python 3.12+) or falls
back to a strict manual check that rejects any member resolving outside
the extraction root or with a link/special type.

Tests in test_archive.py cover: slug/URL construction, happy-path
extract+serve, memoization (one HTTP call for two reads), 404 raises,
retry-then-success, double-failure raises, unsafe traversal member
rejected, symlink rejected on the fallback path.

Assisted-by: Claude:claude-sonnet-4-6
Signed-off-by: Roger Luethi <luethi@osism.tech>
Add two fields to the Config dataclass: archive (bool, default False)
and snapshot_cache (dict, default {}). The driver sets archive=True in
_load_runtime so real runs use the archive backend by default; the
dataclass default False leaves all existing test Configs on the per-file
path with zero edits.

Add --use-raw-get to the driver's argument parser. Passing it keeps
archive=False (the old per-file path) as an escape hatch when the
archive backend misbehaves.

Do not add archive/snapshot_cache to load_config YAML parsing; they are
runtime/CLI state like base_dirs and ref_cache.

Test: load_config-built Config has archive=False and snapshot_cache={};
dataclasses.replace(cfg, archive=True) flips the flag correctly.

Assisted-by: Claude:claude-sonnet-4-6
Signed-off-by: Roger Luethi <luethi@osism.tech>
Wire the archive backend into all six remote read/list functions: read,
read_optional, list_tree, list_dir, list_dir_at_ref, and read_at_ref.
Each remote section calls _note first (preserving progress output for
both paths), then checks config.archive: if True, fetches the tarball
via archive.snapshot_dir and serves from the extracted tree; otherwise
falls through to the existing per-file URL path (the --use-raw-get
escape hatch).

Because Config.archive defaults to False (Task 4), all existing remote
tests continue to mock per-file raw/contents URLs and pass unchanged.
The driver sets archive=True so real CLI runs use the archive backend.

Add two integration tests to test_source.py with archive=True: one
verifying a file read from the tarball, one verifying that two reads
from the same (repo, ref) produce exactly one HTTP call (memoization).

All 325 tests pass.

Assisted-by: Claude:claude-sonnet-4-6
Signed-off-by: Roger Luethi <luethi@osism.tech>
@ideaship ideaship force-pushed the archive-snapshot branch from 7843476 to e58ad99 Compare July 9, 2026 10:52
@ideaship ideaship marked this pull request as ready for review July 9, 2026 10:55
@ideaship ideaship marked this pull request as draft July 9, 2026 10:57
@ideaship ideaship marked this pull request as ready for review July 9, 2026 11:08
@ideaship ideaship moved this from New to In review in Human Board Jul 9, 2026
@ideaship ideaship requested a review from berendt July 9, 2026 11:12
@berendt berendt merged commit fee7356 into main Jul 9, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in Human Board Jul 9, 2026
@berendt berendt deleted the archive-snapshot branch July 9, 2026 11:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants