Skip to content

lore-revision, lore-server: Bound concurrent store lookups during branch push verification - #186

Open
bclarke123 wants to merge 2 commits into
EpicGames:mainfrom
bclarke123:bound-branch-push-lookups
Open

lore-revision, lore-server: Bound concurrent store lookups during branch push verification#186
bclarke123 wants to merge 2 commits into
EpicGames:mainfrom
bclarke123:bound-branch-push-lookups

Conversation

@bclarke123

Copy link
Copy Markdown
Contributor

Closes #185

Problem

Branch push verification fans out one immutable-store metadata lookup per fragment address with effectively unbounded concurrency, so server memory, CPU and handler time all scale with the size of the push.

verify_fragments (lore-server/src/grpc/handlers/branch_push.rs) calls state::collect_new_fragments, whose collect_new_addresses (lore-revision/src/state.rs) spawns a task per address calling immutable_store().get_metadata(). On the AWS store that is a DynamoDB get_item plus an S3 HEAD per fragment. Each JoinSet caps only its own tasks (MAX_TASKS = 1000), and the recursion nests one set per fragmented file, so the budgets multiply. verify_fragments then spawns every query batch at once.

Observed on a 2-vCPU / 4 GB server (S3/DynamoDB-backed stores):

  • 20 GB push (~80k fragments): loreserver grew to ~3.4 GB anonymous heap and was OOM-killed in a restart loop; on a second build it survived but every push failed with Request handler timeout exceeded (50 s default) while pinned at 200 % CPU.
  • Reproduced on an identical test host with a 5 GB push: BranchPush went from 250 MB to 909 MB RSS in 40 s; under a longer run it hit the handler timeout.
  • An allocation profile of the push window (built-in LORE_ALLOCATOR=tracking) showed the live growth to be AWS SDK request machinery — hyper read buffers, TLS connector churn, ~148k HTTP header maps, ~70k SigV4 signings in 70 s — plus collect_new_addresses task state, i.e. roughly one AWS request per fragment, thousands in flight.

Change

  • collect_new_addresses: a process-wide Semaphore (same pattern as lore-storage/src/concurrency.rs) bounds the store lookups across every level of the recursion. The permit covers only the store reads and is released before recursing, so a parent never holds a permit while waiting on children.
  • verify_fragments: cap the query batches in flight instead of spawning all of them. Batches that answered SlowDown are collected and reissued together after the existing retry back-off, so the retry semantics are unchanged.

Both budgets scale with the host, since what an in-flight lookup costs is CPU (TLS, signing) and memory: 64 lookups and 8 batches per CPU, clamped to [128, 1024] and [16, 128]. A 2-vCPU host gets the floor (the values validated below); 16+ cores get 1024 lookups in flight, about what the previous per-set caps allowed in practice, so capable hardware keeps its throughput.

No behavior change beyond concurrency: same results, same errors, no new configuration.

Effect

Same test host, same build otherwise:

push before after
5 GB +650 MB RSS, 40 s, then handler timeout under load
1 GB +80 MB RSS, push complete in 9 s
20 GB (production) OOM loop / timeouts completed; BranchPush peak < 800 MB

Notes

  • The per-CPU factors and clamps are a judgment call; lookups are latency-bound, so even the floor keeps a remote store busy. Happy to tune them or expose them through settings if you prefer.
  • The larger follow-on is to batch the existence checks through ImmutableStore::query (100 addresses per request) instead of per-address get_metadata, which would cut requests by ~100×. That needs the tree to know which addresses are fragment lists (nested lists for large files), so it is left as a separate discussion.

Checks

cargo +nightly fmt --all no changes · cargo clippy --all-targets -- -D warnings --no-deps clean · cargo test -p lore-revision and cargo test -p lore-server green on this branch.

@github-actions github-actions Bot added area:server Server, provider integrations, telemetry area:core Core library and its interfaces (lib, C API); revision, storage, transport, protocol internals labels Sep 3, 2026
@bclarke123

bclarke123 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Looks like the linter failed as the latest version of tinyvec doesn't compile (released ~2h ago), I don't believe it's anything to do with this PR. The smoke test failure is the same one I hit yesterday on #184, which ran fine on a re-run.

Comment thread lore-revision/src/state.rs Outdated

fn store_lookup_limiter() -> &'static Semaphore {
STORE_LOOKUP_LIMITER.get_or_init(|| {
let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

use the cpu counter helper in lore-base

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will do, thanks!

const QUERY_BATCHES_IN_FLIGHT_PER_CPU: usize = 8;
const MIN_QUERY_BATCHES_IN_FLIGHT: usize = 16;
const MAX_QUERY_BATCHES_IN_FLIGHT: usize = 128;
let max_batches_in_flight = (std::thread::available_parallelism().map_or(1, |n| n.get())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same here, use core count helper from lore-base

@bclarke123

Copy link
Copy Markdown
Contributor Author

Updated, switched both to processor_count(), thanks again

@bclarke123
bclarke123 force-pushed the bound-branch-push-lookups branch from 7f00d46 to 90298fa Compare September 7, 2026 23:10
@bclarke123

Copy link
Copy Markdown
Contributor Author

Smoke failure is test_push_missing_fragments, which is failing on upstream main independently of this PR — the same failure appears on the dependabot run 34148922667 (dependency bump only) and on run 34085660968 for fix/scan-into-link-mount, which passed on rerun. Looks like the test is flaky since it landed in 7c3dddc; happy to dig into it if useful. Could a maintainer rerun?

@bclarke123

Copy link
Copy Markdown
Contributor Author

Update: it's deterministic, not flaky — the test fails on main itself (3/3 locally on 9a9c24b; the earlier passing run predated the test). The client reports AddressNotFound as "Address not found: … peer is missing a fragment" and never echoes the server's "Missing fragment" text, so the assertion can't match. Fix in #195 .

…nch push verification

  collect_new_addresses spawned one immutable-store metadata lookup per
  fragment address, capped per JoinSet but multiplied by the recursion
  into fragmented files, and verify_fragments spawned every query batch
  at once. On a remote store that is one request per fragment with
  thousands in flight, so server memory, CPU and handler time grew with
  the push size: a 20 GB push OOM-killed a 4 GB server or timed out.

  Share one lookup budget across the recursion, released before
  recursing so parents never hold it while waiting on children, and cap
  the query batches in flight. SlowDown batches still wait for the retry
  back-off together. Same results and errors, no new configuration.

Signed-off-by: Ben Clarke <ben@arrayofstars.com>
…kup budgets

Per review: size the branch push lookup and query batch budgets with
lore_base::runtime::processor_count(), which also accounts for Windows
processor groups, rather than std::thread::available_parallelism.

Signed-off-by: Ben Clarke <ben@arrayofstars.com>
@bclarke123
bclarke123 force-pushed the bound-branch-push-lookups branch from 90298fa to 8c14968 Compare September 9, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core Core library and its interfaces (lib, C API); revision, storage, transport, protocol internals area:server Server, provider integrations, telemetry

Development

Successfully merging this pull request may close these issues.

Branch push verification memory and time scale with push size (one store lookup per fragment, unbounded concurrency)

2 participants