Skip to content

improve merkle upload throughput: ramp store concurrency + de-wave the store phase - #137

Merged
jacderida merged 4 commits into
WithAutonomi:mainfrom
jacderida:chrisoneil/v2-554-upload-aimd-concurrency-stuck-at-cold-start-floor-8-on-fast
Jul 2, 2026
Merged

improve merkle upload throughput: ramp store concurrency + de-wave the store phase#137
jacderida merged 4 commits into
WithAutonomi:mainfrom
jacderida:chrisoneil/v2-554-upload-aimd-concurrency-stuck-at-cold-start-floor-8-on-fast

Conversation

@jacderida

Copy link
Copy Markdown
Contributor

Two related changes that together cut a production merkle upload from ~73 min to ~34 min for a comparable file (see Validation).

Summary

1. Let the store AIMD limiter ramp off its cold-start floor (V2-554). On fast links the store concurrency limiter sat pinned at ~8 for a whole upload. Three compounding causes fixed:

  • Transport quorum-shortfall pollution. A close-group PUT that misses quorum only because dead/stale relayed peer addresses could not be dialled surfaced as InsufficientPeersNetworkError, dragging the limiter down. This is remote peer churn, not local backpressure. Per-peer failures are now split into timeout (genuine backpressure, keeps InsufficientPeers) vs dial/relay churn (new neutral Error::CloseGroupShortfall, classified ApplicationError, excluded from the limiter's health signal). App-only shortfalls keep their representative error (V2-468).
  • Asymmetric increase/decrease gate. A Decrease fired every min_window_ops (8) samples but an Increase only every window_ops (32), and a Decrease zeroed the increase counter, so a few-percent shortfall permanently starved growth. The store channel now retains the increase credit across a Decrease and relaxes success_target 0.95→0.88 so normal per-chunk noise reads as a healthy window. Genuine congestion (larger shortfall / timeout ceiling) still cuts the cap. Quote channel keeps the classic gate.
  • Per-wave cap snapshot. Store fan-out read the cap once per wave via buffer_unordered; it now rolls a FuturesUnordered re-reading the cap per freed slot so mid-flight growth reaches in-flight work.

2. De-wave the merkle store phase into one cap-bounded fan-out. The whole-file upload stored chunks in sequential fixed 64-chunk waves with a hard barrier between them. On a churny network a handful of chunks whose close-group peers are dead/stale relayed addresses take minutes to dial, and the whole wave cannot advance until its slowest chunk finishes — so the next wave's chunks don't even begin peer resolution. A 609-chunk production upload spent ~70% of its wall-clock in these inter-wave gaps.

  • Payment/proofs are computed up front, so the store phase only needs a memory bound, not batching. merkle_store_with_retry now takes addresses and a store_one(addr) that reads its own body from the spill on demand, so only the ≤cap in-flight stores hold a body. The store cap maxes at 64, so peak memory stays ~64 × MAX_CHUNK_SIZE — the same bound the 64-chunk waves gave — with no barrier.
  • merkle_deferred_retry drops its now-redundant per-batch body reads (the cap bounds memory) and stores each round's whole pending set in one pass.
  • upload_merkle_from_spill (renamed from upload_waves_merkle) makes a single store call over all chunks; deferred retry, stats, progress, and fatal/PartialUpload handling are preserved.

This overlaps but does not fix the underlying dead-relay peer-resolution latency (V2-551); it stops that latency from serializing the upload.

Test plan

  • cargo fmt --all -- --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test -p ant-core --lib — 373 passed. New unit tests: transport-vs-dial shortfall routing; the rebalanced gate ramps where the classic gate pins; the store scheduler re-reads the cap per slot; the store pass has no barrier (a blocked straggler must not deadlock the others); the store pass holds ≤cap bodies in flight (memory bound).
  • ant-cli builds against the changed ant-core.

Validation (production merkle uploads, lon1)

  • Before (wave-based): 609 chunks / 2.36 GB in 73 min. Store completions came in ~48–51/min bursts separated by 5–11 min gaps of zero (the wave barrier). Store cap ramped to 64 but was applied per-wave.
  • After (this branch): 856 chunks / 3.32 GB in 34 min. Logs confirm the single cap-bounded pass (no Wave N/M markers), the store cap ramping 8→16→32→64 with 0 decreases, and sustained 50–90 completions/min with no inter-wave gaps through the main pass, followed by a short deferred-retry tail.

Effective throughput ~3× for a comparable-or-larger file. Both changes verified against real uploads; further gains on churny links are gated by V2-551 (relay affinity).

jacderida and others added 2 commits July 1, 2026 20:10
On fast links the store adaptive-concurrency limiter sat pinned at its
cold-start floor of ~8 for a whole upload, capping throughput. Three
compounding causes, all fixed here:

1. Transport quorum-shortfall pollution. A close-group PUT that misses
   quorum only because dead/stale relayed peer addresses could not be
   dialled surfaced as `InsufficientPeers` -> `NetworkError`, counting
   against `success_rate` and cutting the cap. This is remote peer churn
   (same root cause as V2-551), not local backpressure. Split per-peer
   failures into timeout (genuine backpressure) vs dial/relay churn, and
   route the aggregate: any timeout keeps `InsufficientPeers` (still cuts
   the cap), a pure dial-churn shortfall surfaces the new neutral
   `Error::CloseGroupShortfall` (classified `ApplicationError`), and an
   app-only shortfall keeps its representative error (V2-468). The new
   variant stays a recoverable/deferred shortfall in the merkle retry.

2. Asymmetric increase/decrease gate. A Decrease fires every
   `min_window_ops` (8) samples but an Increase only every `window_ops`
   (32), and a Decrease zeroed the increase counter, so a few-percent
   shortfall permanently starved growth. Retain the increase credit
   across a Decrease AND relax the store `success_target` 0.95 -> 0.88 so
   normal per-chunk noise reads as a healthy window; genuine congestion
   (larger shortfall / timeout ceiling) still cuts the cap. Store-scoped;
   quote keeps the classic gate.

3. Per-wave/per-file cap snapshot. Store fan-out used
   `buffer_unordered(cap)` read once per wave/file, so mid-flight limiter
   growth never reached in-flight work. `merkle_store_with_retry` and the
   wave `store_paid_chunks_with_events` now roll a `FuturesUnordered`
   that re-reads the cap as each slot frees (the byte budget still bounds
   the wave path). Covers the merkle wave, whole-file, and deferred-retry
   paths.

All logic is in ant-core with unit tests for each fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-out

The whole-file merkle upload stored chunks in sequential fixed 64-chunk
waves with a hard barrier between them. On a churny network the wave
barrier is the throughput bottleneck: a handful of chunks whose
close-group peers are dead/stale relayed addresses take minutes to
dial/revalidate, and the whole wave cannot advance until its slowest
chunk finishes — so the next wave's chunks don't even begin peer
resolution. A production 2.36 GB / 609-chunk upload spent ~70% of its
wall-clock in these inter-wave gaps (measured on STG-UL-01), while the
V2-554 store limiter had ramped to 64 and the actual store bursts hit
~50 concurrent. Store concurrency was no longer the long pole; the wave
serialization was.

Remove the waves. The payment/proofs are already computed up front, so
the store phase only needs a memory bound, not batching. Store the whole
file as a single cap-bounded fan-out:

- `merkle_store_with_retry` now takes addresses and a `store_one(addr)`
  that acquires its own body (reads it from the spill on demand), so only
  the <=cap in-flight stores hold a body. The store cap maxes at 64, so
  peak resident memory stays ~64 x MAX_CHUNK_SIZE — the same bound the
  64-chunk waves gave — with no barrier. A slow straggler now overlaps
  with the rest of the file instead of stalling it.
- `merkle_deferred_retry` drops its per-batch `read_bodies`/`batch_size`
  memory-bounding (the cap now bounds memory) and stores each round's
  whole pending set in one pass.
- `upload_merkle_from_spill` (renamed from `upload_waves_merkle`) makes a
  single store call over all chunks instead of looping waves; deferred
  retry, stats, progress, and fatal/PartialUpload handling are preserved.

New tests: the store pass has no barrier (a blocked straggler must not
deadlock the others) and holds at most `cap` bodies in flight (memory
bound). This overlaps but does not fix the underlying dead-relay
resolution latency (V2-551); it stops that latency from serializing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dirvine

dirvine commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Hermes review update

I reviewed PR #137 locally at head c37afd935b13deca00b0fea30c82a1eec1970a72.

Code-level verdict: the upload-throughput direction looks sound, and the focused local checks passed, but I would not call this release-ready until the CI gates below are resolved.

Blocking / release-gating

  • Security Audit is failing on RUSTSEC-2026-0185 (quinn-proto 0.11.14, high severity remote memory exhaustion; RustSec says upgrade to >=0.11.15). I reproduced this locally with cargo audit; the path comes through reqwest 0.13.4 -> saorsa-transport 0.35.1 -> saorsa-core 0.26.2 -> ant-protocol/ant-node/ant-core. This may be upstream dependency work, but for a client/desktop release it needs an explicit decision rather than being silently ignored.
  • CI is still incomplete at time of review: macOS build plus E2E / Merkle E2E jobs are pending.

Warning worth considering before merge

  • upload_merkle_from_spill now runs a whole-file rolling fan-out, but its cap is store_limiter.current().max(1) without the byte budget used by the single-payment store path. The comments say the store cap maxes at 64, but AdaptiveConfig::sanitize allows adaptive.max.store above 64. A custom config with adaptive.max.store = 512 can therefore hold many more full spilled chunk bodies in memory than the old 64-wave bound. Evidence: file.rs:2551-2597, adaptive.rs:128-213, while the single-payment path still applies STORE_INFLIGHT_BYTE_BUDGET at batch.rs:753-819.

I would either clamp the merkle spill store cap to the same 64/byte-budget invariant, or update the comment/config contract so the memory bound is explicit and intentional.

Local checks run

Passed locally:

  • cargo check -p ant-core -p ant-cli
  • cargo fmt --all -- --check
  • cargo clippy -p ant-core --all-targets --all-features -- -D warnings
  • cargo test -p ant-core data::client::adaptive::tests:: --lib --no-fail-fast
  • cargo test -p ant-core data::client::merkle::tests:: --lib --no-fail-fast
  • cargo test -p ant-core data::client::batch::tests:: --lib --no-fail-fast
  • cargo test -p ant-core data::client::chunk::tests:: --lib --no-fail-fast
  • cargo test -p ant-core data::client::file::tests:: --lib --no-fail-fast

No GitHub approval/request-changes submitted; this is a review comment only.

@dirvine

dirvine commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Follow-up from the review team: one additional API/progress-contract warning.

UploadEvent::WaveComplete is still a public progress event and is handled by the CLI progress adapter, but the new merkle spill path no longer emits it. The old upload_waves_merkle emitted WaveComplete after each merkle wave; in the PR, that emission is removed with the wave loop, and upload_merkle_from_spill currently emits ChunkStored only.

Evidence:

  • public enum variant: ant-core/src/data/client/file.rs:64-70
  • CLI consumes it: ant-cli/src/commands/data/file.rs:429-432
  • removed merkle emission in diff around the old wave loop: file.rs diff removes the UploadEvent::WaveComplete { ... } send
  • new merkle path: file.rs:2513-2754 has no replacement final/progress-complete event

This probably does not break the CLI progress bar because ChunkStored still advances it, but it is a behaviour change for any API consumer that relies on WaveComplete as a phase/wave-completion signal. If intentional, I’d document it in the PR/release notes; otherwise emit a terminal WaveComplete/new completion-style event from the merkle path after the store pass/deferred retries.

…event

Address PR WithAutonomi#137 review (Hermes / @dirvine):

- The de-waved `upload_merkle_from_spill` fan-out sized concurrency straight
  from `store_limiter.current()`. The default store cap is 64 (~256 MB of
  spilled bodies, matching the old wave bound), but `AdaptiveConfig::sanitize`
  permits `adaptive.max.store` above 64, so a custom high max could hold many
  more full chunk bodies resident. Clamp the fan-out (and the deferred-retry
  rounds) via `merkle_store_cap`, capped at `MERKLE_STORE_MAX_IN_FLIGHT` (64),
  so peak resident store memory stays at the wave-era ~256 MB regardless of the
  configured max. Throughput is unaffected at the default cap.

- The de-wave removed the per-wave `UploadEvent::WaveComplete` emission, but the
  event is a public phase-completion signal consumed by the CLI progress adapter
  and external API clients. Emit a single terminal `WaveComplete` ("wave 1 of 1")
  after the store pass and deferred retries so consumers keying on it still see
  the store phase complete. Per-chunk `ChunkStored` events drive the bar as before.

New test `merkle_store_cap_clamps_to_memory_bound`. cargo fmt + clippy clean;
374 ant-core lib tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jacderida

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Addressed the two code-level items in f5a7ebf:

Memory bound on the merkle spill fan-out. Good catch — the fan-out sized concurrency straight from store_limiter.current(), and while the default store cap is 64 (~256 MB, matching the old wave bound), AdaptiveConfig::sanitize does allow adaptive.max.store above 64. Introduced merkle_store_cap() which clamps the fan-out (and the deferred-retry rounds) to MERKLE_STORE_MAX_IN_FLIGHT (64), so peak resident store memory stays at the wave-era ~256 MB regardless of the configured max. I deliberately kept the 64-body/256 MB bound rather than adopting the single-payment path's 64 MiB STORE_INFLIGHT_BYTE_BUDGET: at MAX_CHUNK_SIZE = 4 MiB that budget clamps to ~16, which would throttle the throughput this PR is about. Covered by merkle_store_cap_clamps_to_memory_bound, and the comment/constant now make the contract explicit.

WaveComplete progress event. Also addressed — the de-wave dropped the per-wave emission. It now emits a single terminal WaveComplete ("wave 1 of 1") after the store pass and deferred retries, so API consumers keying on it still get a store-phase-complete signal. Per-chunk ChunkStored continues to drive the progress bar.

Security Audit (RUSTSEC-2026-0185, quinn-proto 0.11.14). This is a transitive dependency pulled via saorsa-transport 0.35.1 → saorsa-core 0.26.2, not something this PR introduces or can fix directly in-tree — it needs saorsa-transport to move to quinn-proto >= 0.11.15. That's upstream/fork work and a release-gating decision for the maintainers rather than part of this throughput change; happy to help with a fork-propagation bump if you'd like it tracked separately. The pending macOS/E2E jobs are just CI in flight.

jacderida added a commit to jacderida/ant-client that referenced this pull request Jul 2, 2026
Follow-up to the PR WithAutonomi#137 review discussion. `WaveComplete` had no real
consumer: the CLI handler only mirrored `ChunkStored` (`set_position(stored)`),
and the ant-ui Tauri backend maps it to `None` (never forwarded to the
frontend). Emitting a synthetic "wave 1 of 1" from the de-waved merkle path to
preserve it was therefore pointless.

Remove the event entirely instead: drop the `UploadEvent::WaveComplete` variant,
both emissions (the single/wave-batch path and the merkle terminal one added in
the previous commit), and the redundant CLI match arm. Per-chunk `ChunkStored`
drives all upload progress bars, so behaviour is unchanged. `UploadEvent` is an
internal in-process type (derives only Debug/Clone, not serialised), so this is
not an API-schema change; ant-ui pins ant-core by tag and will drop its own dead
match arm when it next bumps.

cargo fmt + clippy clean; 374 ant-core lib tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jacderida

Copy link
Copy Markdown
Contributor Author

Follow-up on the WaveComplete item: rather than keep it with a synthetic terminal emission, I removed the event entirely (bea9ea8).

Checked the actual consumers first:

  • ant-cli: the handler only mirrored ChunkStored (pb.set_position(stored_so_far)), so it was redundant.
  • ant-ui (src-tauri/src/autonomi_ops.rs): maps UploadEvent::WaveComplete { .. } => None — it's explicitly dropped, never forwarded to the frontend.

With no real consumer, emitting a "wave 1 of 1" was pointless, so I dropped the UploadEvent::WaveComplete variant, both emissions (the single/wave-batch path and the merkle terminal one), and the CLI arm. Per-chunk ChunkStored drives every progress bar, so behaviour is unchanged. UploadEvent is internal (derives only Debug/Clone, not serialised), so no API-schema impact; ant-ui pins ant-core by tag and can drop its dead match arm on its next bump.

Follow-up to the PR WithAutonomi#137 review discussion. `WaveComplete` had no real
consumer: the CLI handler only mirrored `ChunkStored` (`set_position(stored)`),
and the ant-ui Tauri backend maps it to `None` (never forwarded to the
frontend). Emitting a synthetic "wave 1 of 1" from the de-waved merkle path to
preserve it was therefore pointless.

Remove the event entirely instead: drop the `UploadEvent::WaveComplete` variant,
both emissions (the single/wave-batch path and the merkle terminal one added in
the previous commit), and the redundant CLI match arm. Per-chunk `ChunkStored`
drives all upload progress bars, so behaviour is unchanged. `UploadEvent` is an
internal in-process type (derives only Debug/Clone, not serialised), so this is
not an API-schema change; ant-ui pins ant-core by tag and will drop its own dead
match arm when it next bumps.

cargo fmt + clippy clean; 374 ant-core lib tests pass.

BREAKING CHANGE: The `UploadEvent::WaveComplete` variant is removed from
ant-core's public `UploadEvent` enum. Any consumer that matches on it must drop
that arm; upload progress is fully covered by `UploadEvent::ChunkStored`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jacderida
jacderida force-pushed the chrisoneil/v2-554-upload-aimd-concurrency-stuck-at-cold-start-floor-8-on-fast branch from bea9ea8 to feec056 Compare July 2, 2026 15:46
@dirvine

dirvine commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Follow-up review on latest head feec056de9b54cbb10ea524cdf1c74ece1859cec.

The two code-level comments I left are addressed:

  • The merkle spill store path now clamps through merkle_store_cap(...) / MERKLE_STORE_MAX_IN_FLIGHT = 64, including deferred retries, so a custom adaptive.max.store > 64 no longer blows past the old wave-era body-memory bound.
  • UploadEvent::WaveComplete has been removed from ant-core and the CLI consumer, so the previous progress-event mismatch is no longer a dangling internal contract. This is still a public API behaviour/type change, so it is worth calling out in release notes if any external consumer uses UploadEvent, but I do not see an internal break.

Local checks passed:

  • cargo check -p ant-core -p ant-cli
  • cargo fmt --all -- --check
  • cargo clippy -p ant-core --all-targets --all-features -- -D warnings
  • cargo clippy -p ant-cli --all-targets --all-features -- -D warnings
  • cargo test -p ant-core data::client::adaptive::tests:: --lib --no-fail-fast — 87 passed
  • cargo test -p ant-core data::client::merkle::tests:: --lib --no-fail-fast — 42 passed
  • cargo test -p ant-core data::client::file::tests:: --lib --no-fail-fast — 21 passed

Current PR checks at this head: format, clippy, unit tests, documentation, and both build jobs are green. Security Audit is still failing on the unrelated quinn-proto advisory as discussed, and E2E / Merkle E2E jobs are still pending at the time of this comment.

With the security-audit exception handled separately, I do not have remaining code-level objections to this PR. I would still wait for the pending E2E/Merkle E2E jobs before cutting the client/desktop release artifact.

@dirvine dirvine 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.

Approved on code review for head feec056de9b54cbb10ea524cdf1c74ece1859cec.

I re-checked the addressed review items:

  • merkle spill store concurrency is now clamped via MERKLE_STORE_MAX_IN_FLIGHT = 64 / merkle_store_cap(...), including deferred retries;
  • the previous UploadEvent::WaveComplete mismatch is resolved by removing the event and the CLI consumer;
  • local focused checks passed: cargo check -p ant-core -p ant-cli, fmt, clippy for ant-core and ant-cli, and focused adaptive/merkle/file unit tests.

Caveat: this approval is for the PR code changes. The Security Audit failure (RUSTSEC-2026-0185 / quinn-proto) remains outside this PR per Chris’s note, and the pending E2E/Merkle E2E jobs should still be treated as release/merge gates by the maintainer.

@jacderida
jacderida merged commit 278e6ea into WithAutonomi:main Jul 2, 2026
11 of 12 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.

2 participants