Skip to content

perf(import): warmed first articles, hedged fast-fail STATs, dead-post fast path - #943

Merged
javi11 merged 11 commits into
mainfrom
perf/import-warm-store-dead-post-fast-path
Sep 8, 2026
Merged

javi11 merged 11 commits into
mainfrom
perf/import-warm-store-dead-post-fast-path

Conversation

@javi11

@javi11 javi11 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #941 (which merged the first two fast-fail changes). These are the remaining commits from the same benchmarking session against AIOStreams on the nzb-streaming-benchmarks harness, cherry-picked onto main. Each is TDD'd.

Fast-fail probe (internal/importer/validation)

  • Hedge stragglers on an arrival lull, on the priority lane. A debug import showed the real straggler shape: most of the 64 sampled STATs answer in ~150 ms and anywhere from 1 to 22 sit to the 2 s ceiling — STATs pipelined behind slow/cold connections, not slow articles (a priority-lane re-issue answers in 50–90 ms). Once ≥8 answers are in and the sweep goes quiet for clamp(3×median, 250–750 ms), every outstanding id is re-issued with Priority: true; decisions and answers are logged at debug.
  • Pass with ≤2 unverified of a full sample. One article whose STAT neither the original nor the hedge could get answered held a healthy import 4.5 s; the probe answers from a sample and such an article is handled at stream time like the thousands never sampled. Per-file sweep unchanged.
  • Dead posts: verdict from an 8-STAT first wave, no per-file sweep; sweep attempts cut once their answers condemn the release; 16-STAT opening chunk. Three dead posts in a row used to cost hundreds of STATs answered as slow 430 spool lookups that stayed pipelined on the connections and slowed the next import (2–5 s, once inconclusive; a damaged set swept for 18 s). After: dead posts fail in 2.6–8 s and the 7z imports that follow take 0.6–0.8 s.

Cold open / import critical path

  • Publish warmed first articles to the streaming segment store (the 256 MB memory tier is on even without cache_path): cold TTFB 55 → 3–10 ms. Also warm the largest clean-named video when a store is wired.
  • Archive analysis reads warmed articles back (import-scoped cache reads through to the store) and warm-up pre-fetches the last 7z volume's tail: 7z header pass 1/3 → 3/3 hits, ~450 → ~40 ms.
  • Parse round trips off the critical path: representative middle-segment header fetched during warm-up; PAR2 index reader given the store; store-only fetches detached from the wait the parse blocks on (season pack 0.9 → 0.6 s).

Observability (internal/webdav)

  • WARN when a streamed body ends on a read error while the client is still connected. A bench playback died silently ("terminated", no health row, no log); http.ServeContent swallows the reader's error. Client cancels excluded.

Results (same-session head-to-head vs AIOStreams, 9 shared entries, 11 passes)

AIOStreams AltMount
Cold TTFB 6–22 ms 3–10 ms (was 55)
Seq MB/s 33–90 38–92
Playback p05 6–42 9–56
Full seek 480–1140 ms 204–588 ms
CPU s/GiB 6.3–11.3 5.0–7.5
Click→byte 0.61–1.62 s 0.98–1.16 s (7z entries 2.9 → 0.6–1.2 s; season pack 0.9 → 0.6–0.7 s; dead posts no longer slow the import after them)
RSS/item 430–560 MiB 640–690 MiB (memory tier + GC headroom, unchanged)
Capability 1 gap / 0 wrongly served 1 gap / 0 wrongly served

Ranges span the evening's link conditions; in like-for-like slots AltMount matched or led every column except memory.

Test plan

  • go test -race ./... green on top of main; new tests: fast_fail_hedge_test.go, fast_fail_deadwave_test.go, segment_cache_fallback_test.go, parser_warm_store_test.go, adapter_get_test.go
  • go build ./... && go vet ./...
  • Eleven full bench passes plus targeted passes and debug-logged repro imports
  • CI golangci-lint (local lint is broken against Go 1.27 export data)

http.ServeContent swallows the reader's error and stops writing, so a usenet
fetch that gives up mid-stream reached the client as a truncated body with
nothing in the server log to explain it. A bench playback died this way and
the INFO log was empty. Wrap the file so the handler sees how the body ended
and record the path, range, bytes served and error.
…store

Import already fetches the first article of every file (WarmFirstSegments)
but kept only a 16 KiB head for the parser, so the cold open that follows an
import fetched the same article from the provider again: ~55 ms cold TTFB
against ~5 ms warm in the bench, and a flat cost on every click→byte.

The parser now hands the whole decoded body to the streaming segment store —
the in-memory tier that is on by default, plus the disk cache when enabled —
so the first read of a freshly imported file is a cache hit. Cold TTFB on the
bench drops to 3–10 ms.
… a segment store is wired

Clean-named videos skip the import-time first-segment fetch to save
bandwidth, which also meant the one file a player opens first never reached
the segment store and paid a provider round trip on its cold open (~85 ms vs
3-10 ms for everything else). Warm just the largest such file, only when a
store can keep the article; the skip is unchanged for the rest and whenever
caching is off.
A client abort cancels the request context and the reader surfaces that
cancellation as its read error; a bench pass logged 36 of those as stream
failures. Only a body cut short while the client was still listening is a
server-side failure.
…iority lane

A bench pass showed 9 of 64 probe STATs queued behind other traffic while the
other 55 answered in ~150 ms — under the 90% threshold, so no hedge fired and
the import rode the 2 s attempt ceiling again. Arm the hedge at 75% and send
the re-issued STATs down the priority lane: on the normal lane they would only
join the queue that made the originals straggle. A uniformly slow or dead
release never reaches the threshold, so it is not hedged.
…f a reported fraction

A debug import showed the probe's real straggler shape: 42 of 64 STATs
answered in ~150 ms and 22 sat to the 2 s ceiling, then 19 of those answered
instantly on retry and the last 3 were hedged and answered in 50-90 ms on the
priority lane. The stragglers are STATs pipelined on slow or cold connections,
not slow articles, and their number varies from one to a third of the probe —
a 75% reported threshold missed the case that mattered.

Once at least 8 answers are in, every answer restarts a lull timer sized from
the observed median; when the sweep goes quiet with ids outstanding, all of
them are re-issued on the priority lane. Both hedge decisions and answers are
logged at debug level so the next unexplained 2 s import can be read from the log.
…m the last 7z volume's tail

The RAR and 7z header passes fetch through an import-scoped cache that
started empty, so the first volume's head — fetched moments earlier by the
warm-up — was fetched again ("pass=7z-header hits 1 misses 2"), and the 7z end
header at the tail of the last volume was a second serial round trip. Each
cost ~250 ms on the import's critical path for every 7z release.

The import cache now reads through to the streaming segment store the warm-up
publishes into, and the warm-up also fetches the last two articles of the
highest-numbered .7z.NNN volume when a store is wired. Both are no-ops when
caching is off. The archive processors take the store through an optional
setter so their interfaces and test doubles are unchanged.
… path

After every first segment is in, the parse still made two provider round
trips before anything could be written: the representative middle segment's
yEnc header (release-wide part size) and the PAR2 index, read through a
reader with no cache so its first segment — fetched by the warm-up seconds
earlier — was fetched again. ~400 ms of a season pack's 0.9 s import.

The warm-up now fetches the representative header alongside the probe, and
the PAR2 index reader is given the segment store the warm-up publishes into.
Fetches that exist only to fill the store (the largest video's first article,
the 7z tail) no longer hold up the parse: they run detached from the warm-up
wait, bounded by their own timeout.
…ATs of a full sample

A healthy import sat for 4.5 s because one article of the 64 sampled stayed
unanswered through two 2 s attempts — after the priority-lane hedge had been
tried too, so the article is slow at the provider itself, not queued. The
probe answers "is this post damaged?" from a sample; the thousands of
articles it never sampled are handled at stream time, and so is this one.

With every other sampled article healthy, the probe now passes after the
first attempt when at most two of a sample of 32 or more remain unverified,
logging what it left unchecked. Small samples and the per-file sweep — which
maps exactly which files are broken — keep waiting.
… its per-file sweep

Three dead releases in a row cost the imports after them: each was STAT-ed
64 times over three attempts and then swept per file, hundreds of STATs the
provider answers as slow 430 spool lookups that stay pipelined on the
connections — the next healthy import's probe queued behind them and came
back inconclusive or took 14 s. Eight sampled articles all missing is
already the verdict.

The release probe now checks a first wave of 8 (the edges plus random middle
articles) to completion; when those misses condemn the release
(releaseLooksDead) every file is marked broken without the sweep, otherwise
the rest of the sample is checked as before. A healthy post pays one extra
STAT round trip.
…release; open with a small chunk

A damaged post (first article of every volume gone) reaches the per-file
sweep, which dispatched 64 STATs and waited the attempt out — 18 s on a
provider answering 430s slowly — although the first dozen misses had already
condemned it, and every 430 left in flight was a slow spool lookup pipelined
on a connection for the next import's probe to queue behind.

A sweep attempt now cancels itself the moment the definitive answers meet
releaseLooksDead, returning what it has so the existing dead-release path
condemns the rest, and the sweep's first chunk is capped at 16 STATs so a dead
post leaves few of them in flight.
@javi11
javi11 merged commit 026bd93 into main Sep 8, 2026
2 checks passed
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