Skip to content

historyarchive: bound the cache writer and cache only completed downloads - #5975

Open
karthikiyer56 wants to merge 8 commits into
mainfrom
fix/strkey-length-and-cache-writer-lifetime
Open

historyarchive: bound the cache writer and cache only completed downloads#5975
karthikiyer56 wants to merge 8 commits into
mainfrom
fix/strkey-length-and-cache-writer-lifetime

Conversation

@karthikiyer56

@karthikiyer56 karthikiyer56 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Three fixes in historyarchive:

  1. The cache can no longer truncate what you're reading. Before: the caller read a file out of the cache while a goroutine filled it — so when the fill stopped early (size limit, error, closed reader), the caller's file silently ended mid-way, with no error. Now the caller reads the download directly, and the cache keeps a copy on the side. That copy can be abandoned at any moment and the caller never notices.
  2. An archive pool shares one cache. N mirrors used to build N caches over the same directory — N× the size budget, and no member could see the others' files to evict them. Now there's one cache, built only after at least one mirror actually connects.
  3. One bad request no longer panics scan/mirror, and no longer tricks repair into re-uploading a bucket that was never missing.

Two rules make the cache fix hold everywhere:

  • Only whole files are ever served from the cache. A file mid-download is invisible to everyone but its downloader.
  • Size limits mean "don't keep a copy" — never "cut the download short".

Follow-up: #5976

The cache stores exactly what a mirror sent — it cannot tell a good file from a bad one. So a file that streams cleanly end-to-end but then fails its content-hash check still gets cached, and every retry is served that same cached copy instead of asking a different mirror. Fixing that needs a purge hook in the interface plus caller changes here and in horizon/rpc/galexie — spec'd out in #5976 rather than grown into this PR.


The cache fill no longer sits between caller and data

On main, a cache miss returns a reader over the cache file while a detached goroutine copies the upstream into it — forever, with no size limit, no matter what the caller does. Just bounding that copy (this PR's first iteration) made things worse: every abort committed a partial file, and its readers saw a clean EOF at the truncation point.

The redesign removes the coupling instead of managing it:

  • A miss hands the caller a reader over the upstream itself (cacheFillingReader); each chunk the caller reads is also written into the cache entry. The caller's data path never depends on the cache.
  • When caching must stop — file outgrows MaxCacheFileSize, directory hits the 10 GiB budget, a cache write fails — the entry is deleted and the reader keeps streaming.
  • While a path is mid-fill, concurrent requests bypass the cache and download their own copy. (fscache cannot error-out readers of an abandoned in-flight stream — they'd see a clean EOF — so isolation is the only safe option.)
  • Gzip consumers stop reading right after the gzip trailer, before the raw io.EOF. Close on a still-active fill therefore does one bounded probe read: immediate EOF → the file was fully consumed → commit; anything else → discard. A timer closes the upstream if the probe stalls, so Close cannot hang.
  • LRU eviction now trims to 8 GiB, deliberately below the 10 GiB budget. The two limits are measured differently (eviction skips open files and metadata), so sharing one number let a full cache evict nothing, permanently disabling caching.
  • Cache-hit readers tolerate a double Close (the fscache reader underneath panics on the second one).
  • Budget bookkeeping uses atomic counters, re-measures the directory at most once a second without holding a lock across the walk, and checks the budget every 4 MiB written instead of every Read.

One cache per pool, built only on success

  • Every pool member used to build its own cache over the shared CachePath: budget × pool size, blind eviction, and each member re-downloading the same buckets. The pool now builds one cache and attaches it to every member.
  • Construction order matters: building the cache wipes CachePath and starts an eviction timer that can never be stopped. The pool now connects first and builds the cache only if something succeeded — a retry loop around a failing NewArchivePool no longer wipes the directory or leaks a timer per attempt.

scan/mirror workers stop panicking — and stop misclassifying

  • Worker goroutines in Scan and Mirror panicked the whole process when one request failed (has.Buckets(), BucketExists, CategoryCheckpointExists). They now feed the error accumulator sitting next to them and move on.
  • De-panicking BucketExists exposed a second bug: the bucket was marked referenced before the failed check, which left it permanently classified "missing" — and repair would re-upload it. The reference is now forgotten on error, so the next checkpoint referencing the bucket rechecks it.

Behaviour changes

  • Abandoning a stream mid-file discards its cache fill; the next request is a miss. (A file nobody finished reading shouldn't occupy budget.)
  • Concurrent requests for a mid-fill path each download their own copy — bandwidth traded for correctness. The first completed fill serves everyone afterwards.
  • A file too big for the cache, or arriving while the cache is full, is served in full and simply not cached.

Tests

  • historyarchive/archive_cache_test.go: readers can consume files past both size limits and past a full cache, untruncated; the cache stops growing when a reader closes or a stream is rejected; in-flight requests bypass the cache and survive an abandoned fill; Close is idempotent on hit and miss paths; a pool fetches each path once; a pool that fails to construct leaves CachePath untouched.
  • historyarchive/archive_test.go: a bucket whose existence check failed is neither reported missing nor blocked from a recheck.

Copilot AI balanced review requested due to automatic review settings August 10, 2026 01:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enforces canonical StrKey payload lengths and bounds/cancels history archive cache writes.

Changes:

  • Adds fixed-length validation for StrKey validators.
  • Cancels cache downloads when readers close.
  • Adds per-file and total cache limits with tests.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
strkey/main.go Enforces canonical payload lengths.
strkey/isvalid_length_test.go Tests valid, short, and oversized payloads.
historyarchive/archive.go Adds cancellation and cache size enforcement.
historyarchive/archive_cache_test.go Tests cancellation, idempotent closing, and limits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread historyarchive/archive.go Outdated
Comment thread historyarchive/archive.go Outdated
karthikiyer56 and others added 6 commits August 9, 2026 21:06
Archive.cachedGet starts a goroutine that copies an archive response into
the on-disk cache file, and returns a reader over that file to the caller.
The two are independent: the goroutine runs to upstream EOF no matter what
the caller does, and it has no size limit. Closing the reader does not stop
it, so a response the caller rejected is still copied to disk in full.

The 10 GiB LRU budget configured in Connect cannot apply to a copy that is
still running either. lruHaunter.Scrub skips entries where InUse() is true,
and the writer holds a handle for the duration, so a growing entry is
counted in neither the size tally nor the reap list.

Each download now gets a cancellable context. The reader handed back to the
caller cancels it on Close, and the copy goroutine closes the upstream body
on cancellation, since a blocked Read does not observe a context otherwise.
Bytes written by in-flight copies are counted against the cache budget, so
the configured size is enforced while a copy is running rather than only
after it finishes. A new ArchiveOptions.MaxCacheFileSize caps any single
cached file, defaulting well above the largest file the pubnet archive
currently serves.

Copies that are cancelled or exceed a limit return an error, which routes to
the existing removal branch, so a partial file is not left as a valid entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NewArchivePool calls Connect once per URL with the same options, so every
archive in the pool builds its own cache over the one CachePath. Each cache
carries its own size budget, and each only knows about the files it
downloaded itself.

Two consequences. The directory's real ceiling is the budget multiplied by
the number of archives rather than the budget. And because eviction only
considers the files one archive downloaded, a pool splitting its downloads
between members can hold a full directory while no single member's tally is
large enough to evict anything, so the cache fills and then stops accepting
new files.

Sharing one cache across the pool gives it one budget and one view of the
directory, so eviction sees everything. It also means a bucket downloaded
through one archive is a cache hit on the others, instead of being fetched
again once per member.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…king

The worker goroutines in Scan and Mirror panic when a call fails, which
takes down the whole process rather than the one checkpoint being worked
on. Every one of these sites sits next to the error accumulator the
surrounding loop already uses for its other failures.

Route them through that accumulator and move on to the next checkpoint,
which is how the rest of both loops already handle a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cache fill no longer sits between the caller and the data. A miss
now hands the caller a reader over the upstream itself, which copies
each chunk into the cache entry as a side effect. When caching must
stop -- per-file limit, total budget, write error -- the entry is
discarded and the caller keeps streaming, instead of getting a clean
EOF mid-file. Fills are private to the caller that starts them:
concurrent requests for the same path bypass the cache, because
in-flight fscache readers see abandoned fills as silent truncation.

Since gzip consumers stop reading right before the raw EOF, Close on a
still-active fill does one bounded probe read to distinguish "consumed
everything" (commit) from "abandoned mid-file" (discard).

The LRU eviction target now sits below the hard budget so the two
differently-measured limits cannot wedge each other, cache-hit readers
tolerate double Close, usage accounting no longer holds a lock across
directory walks, and the pool builds its shared cache only after at
least one archive connects, so failed construction cannot wipe the
cache directory or leak eviction timers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ScanBuckets marked a bucket referenced before checking its existence,
so a transient BucketExists error left the bucket permanently
classified as missing: nothing ever marked it existing, and later
checkpoints referencing it skipped the recheck. Repair would then
re-download and re-upload a bucket that was present all along. Forget
the reference on error so the status stays unknown and the next
reference rechecks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikiyer56
karthikiyer56 force-pushed the fix/strkey-length-and-cache-writer-lifetime branch from 0c1baa5 to 035fdbe Compare August 10, 2026 04:07
@karthikiyer56 karthikiyer56 changed the title strkey, historyarchive: enforce canonical payload lengths and bound the cache writer historyarchive: bound the cache writer and cache only completed downloads Aug 10, 2026
@karthikiyer56
karthikiyer56 requested a review from a team August 10, 2026 04:15
karthikiyer56 and others added 2 commits August 9, 2026 21:50
The only function that actually collides with the path package,
ListCategoryCheckpoints, takes subpath instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikiyer56
karthikiyer56 force-pushed the fix/strkey-length-and-cache-writer-lifetime branch from 9557064 to be518fd Compare August 10, 2026 04:55

@Shaptic Shaptic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a complicated fix for a non-issue, but LGTM nonetheless.

Comment thread historyarchive/mirror.go
if err != nil {
panic(errors.Wrap(err, "error getting buckets"))
atomic.AddUint32(&errs, noteError(errors.Wrap(err, "error getting buckets")))
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this no longer fatal?

Comment thread historyarchive/archive.go
Comment on lines +726 to +727
// Another goroutine is filling this entry — download independently
// rather than read a file that may be abandoned mid-write (see acquire).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would a download be abandoned mid-write with any more likelihood than the direct fetch?

Comment thread historyarchive/archive.go
// Paths whose entries are being written right now. Only completed entries
// are ever served; see acquire.
fillMu sync.Mutex
filling map[string]struct{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use support/collections/set for this

Comment on lines +78 to +80
// One cache for the whole pool: per-member caches over the same directory
// would multiply the size budget and hide each member's files from the
// others' eviction.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idk what this even means - the path is the same across all members of the pool so there already is a single cache, no?

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.

3 participants