ant_core/data/client/mod.rs
1//! Client operations for the Autonomi network.
2//!
3//! Provides high-level APIs for storing and retrieving data
4//! on the Autonomi decentralized network.
5
6pub mod adaptive;
7pub mod batch;
8pub mod cache;
9pub(crate) mod cached_merkle;
10pub(crate) mod cached_single;
11pub mod chunk;
12pub mod data;
13pub mod file;
14pub mod merkle;
15pub mod payment;
16pub mod quote;
17
18use crate::data::client::adaptive::{AdaptiveConfig, AdaptiveController, ChannelStart, Outcome};
19use crate::data::client::cache::ChunkCache;
20use crate::data::error::{Error, Result};
21use crate::data::network::Network;
22use crate::data::peer_cache;
23use ant_protocol::evm::Wallet;
24use ant_protocol::transport::{MultiAddr, P2PNode, PeerId};
25use ant_protocol::{XorName, CLOSE_GROUP_SIZE};
26use std::path::PathBuf;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::Arc;
29use tracing::debug;
30
31/// Width of the chunk PUT-target set (initial writes plus fallback): the
32/// closest `PUT_TARGET_WIDTH` peers to the address.
33///
34/// Mirrors the node-side `K_BUCKET_SIZE` / `PAID_QUOTE_ISSUER_CLOSENESS_WIDTH`
35/// (20): a node accepts a reused payment proof only when one of the proof's
36/// closest-`CLOSE_GROUP_SIZE` quote issuers is within its own local 20-closest,
37/// so trying peers past this width is pointless.
38pub(crate) const PUT_TARGET_WIDTH: usize = 20;
39
40/// Classify a `data::error::Error` into a controller `Outcome`.
41///
42/// Capacity signals (Timeout / NetworkError) drive the controller
43/// down; application errors do not. The mapping is conservative:
44/// anything that COULD be transport-related is treated as a network
45/// signal, because under-classifying a real network failure as
46/// "application error" makes the controller blind to genuine stress.
47///
48/// Mapping policy:
49/// - `Timeout` -> `Timeout` (per-op deadline elapsed)
50/// - `Network`, `InsufficientPeers`, `Io` -> `NetworkError` (transport
51/// layer reported failure)
52/// - `Protocol`, `Storage` -> `NetworkError` (these wrap remote errors
53/// that frequently include peer disconnects mid-stream — under
54/// network stress these are how transport failures surface)
55/// - `PartialUpload` -> `NetworkError` (literal capacity signal: some
56/// chunks could not be stored)
57/// - `AlreadyStored`, `Encryption`, `Crypto`, `Payment`,
58/// `Serialization`, `InvalidData`, `NotFound`, `SignatureVerification`,
59/// `Config`, `InsufficientDiskSpace`, `CostEstimationInconclusive`,
60/// `Cancelled` -> `ApplicationError` (would happen on a perfectly
61/// healthy link; `Cancelled` is caller-initiated and must not be retried
62/// as a transport failure; `NotFound` is a definitively absent record
63/// reported over a working link, not a transport symptom)
64/// - `RemotePut` -> `ApplicationError` (the remote node responded with a
65/// structured rejection — the transport succeeded, so the node declined
66/// at the application layer; not a local capacity signal)
67/// - `CloseGroupShortfall` -> `ApplicationError` (a quorum shortfall caused
68/// by close-group dial/relay churn with no PUT-response timeouts — remote
69/// peer churn, not local backpressure; a timeout-bearing shortfall keeps
70/// `InsufficientPeers`/`NetworkError` instead, so genuine congestion still
71/// cuts the cap — V2-554)
72pub(crate) fn classify_error(err: &Error) -> Outcome {
73 match err {
74 Error::Timeout(_) => Outcome::Timeout,
75 Error::Network(_)
76 | Error::InsufficientPeers(_)
77 | Error::Io(_)
78 | Error::Protocol(_)
79 | Error::Storage(_)
80 | Error::PartialUpload { .. } => Outcome::NetworkError,
81 Error::AlreadyStored
82 | Error::Encryption(_)
83 | Error::Crypto(_)
84 | Error::Payment(_)
85 | Error::Serialization(_)
86 | Error::InvalidData(_)
87 // A definitively absent record, reported over a working link —
88 // the peers answered, there was just nothing stored there. Not a
89 // transport symptom, so it must not push the limiter down.
90 | Error::NotFound(_)
91 | Error::SignatureVerification(_)
92 | Error::Config(_)
93 | Error::InsufficientDiskSpace(_)
94 | Error::CostEstimationInconclusive(_)
95 | Error::Cancelled(_)
96 | Error::BadQuoteBinding { .. }
97 | Error::BadQuoteCommitment { .. }
98 // A remote node responded with a structured rejection — the
99 // transport round-trip succeeded, so the node declined at the
100 // application layer (payment/disk/quote/pool). Not a local
101 // capacity signal; recorded but must not push the limiter down.
102 | Error::RemotePut { .. }
103 // A close-group PUT shortfall caused purely by dial/relay churn
104 // (dead/stale relayed peer addresses), with no PUT-response
105 // timeouts to signal local backpressure. Remote peer churn, not
106 // "client sending too fast" — must not push the limiter down
107 // (V2-554). A shortfall that DID time out keeps `InsufficientPeers`
108 // (`NetworkError`) so real congestion still cuts the cap.
109 | Error::CloseGroupShortfall(_) => Outcome::ApplicationError,
110 }
111}
112
113/// Compute XOR distance between a peer's ID bytes and a target address.
114///
115/// Uses the first 32 bytes of the peer ID (or fewer if shorter) XORed
116/// with the target address. The returned byte array sorts
117/// lexicographically from closest to furthest.
118pub(crate) fn peer_xor_distance(peer_id: &PeerId, target: &[u8; 32]) -> [u8; 32] {
119 let peer_bytes = peer_id.as_bytes();
120 let mut distance = [0u8; 32];
121 for (i, d) in distance.iter_mut().enumerate() {
122 let peer_byte = peer_bytes.get(i).copied().unwrap_or(0);
123 *d = peer_byte ^ target[i];
124 }
125 distance
126}
127
128/// Default timeout for lightweight network operations (quotes, DHT lookups) in seconds.
129const DEFAULT_QUOTE_TIMEOUT_SECS: u64 = 10;
130
131/// Default timeout for the per-peer chunk GET response and any other
132/// caller that explicitly reads `store_timeout_secs`, in seconds.
133///
134/// Note despite the name: this knob does **not** govern the non-merkle
135/// chunk PUT response timeout — that path uses the
136/// `STORE_RESPONSE_TIMEOUT` constant in `chunk.rs` directly. Nor does
137/// it govern the merkle batch PUT timeout — see
138/// `DEFAULT_MERKLE_STORE_TIMEOUT_SECS`.
139///
140/// 10 s matches the pre-existing `main` default and intentionally
141/// excludes residential-upload tuning, which is Mick's PR #78
142/// territory (splitting GET into its own field).
143const DEFAULT_STORE_TIMEOUT_SECS: u64 = 10;
144
145/// Default timeout for **merkle batch** chunk store operations in seconds.
146///
147/// Separate from `DEFAULT_STORE_TIMEOUT_SECS` because merkle PUTs carry
148/// an extra storer-side cost: the payment verifier runs an iterative
149/// DHT lookup (`CLOSENESS_LOOKUP_TIMEOUT` in `ant-node`, **240 s**
150/// post-PR #89) before accepting the proof.
151///
152/// This timeout MUST be >= the storer-side `CLOSENESS_LOOKUP_TIMEOUT`
153/// plus padding for the store-response round-trip and storer-local
154/// I/O. Otherwise the client gives up while the storer is still
155/// happily verifying, the storer wastes CPU/bandwidth on a chunk the
156/// client has already discarded, and the client re-targets a
157/// different close-K member — potentially double-storing the same
158/// chunk and polluting routing.
159///
160/// 270 s = 240 s (storer lookup) + 30 s padding (network RTT + LMDB
161/// put + fsync + clock skew tolerance).
162///
163/// This invariant must be re-validated if either side's timeout
164/// changes. Empirically surfaced as "every cross-region merkle chunk
165/// times out at 10 s" on a 210-node 7-region testnet run on
166/// 2026-05-12; bumping to 270 s flipped that 0/22 -> 9/9 pass rate.
167const DEFAULT_MERKLE_STORE_TIMEOUT_SECS: u64 = 270;
168
169/// Default timeout for chunk GET response operations in seconds.
170const DEFAULT_CHUNK_GET_TIMEOUT_SECS: u64 = 10;
171
172/// Default quote concurrency: high because quoting is pure network I/O
173/// (DHT lookups + small request/response messages) with no CPU-bound work.
174const DEFAULT_QUOTE_CONCURRENCY: usize = 32;
175
176/// Default store concurrency: moderate because each chunk PUT sends ~4MB
177/// to 7 close-group peers. At 8 concurrent stores, ~225MB of outbound
178/// traffic can be in flight. Users on fast connections can increase this
179/// with --store-concurrency; users on slow connections can decrease it.
180const DEFAULT_STORE_CONCURRENCY: usize = 8;
181
182/// Configuration for the Autonomi client.
183#[derive(Debug, Clone)]
184pub struct ClientConfig {
185 /// Per-op timeout for lightweight network operations (quotes,
186 /// DHT lookups), in seconds. The adaptive controller does NOT
187 /// currently size timeouts; this remains a static knob.
188 pub quote_timeout_secs: u64,
189 /// Per-op timeout, in seconds, for the chunk GET response path
190 /// (`chunk_get_from_peer`) and any other caller that reads this
191 /// field directly.
192 ///
193 /// Note despite the historical name `store_timeout_secs`: this
194 /// knob does **not** govern the non-merkle chunk PUT response
195 /// timeout (that path uses the `STORE_RESPONSE_TIMEOUT` constant
196 /// in `chunk.rs`) and does **not** govern the merkle batch PUT
197 /// timeout (see `merkle_store_timeout_secs`). Rename pending in
198 /// Mick's PR #78 which adds a dedicated `chunk_get_timeout_secs`.
199 ///
200 /// The adaptive controller does NOT currently size timeouts;
201 /// this remains a static knob.
202 pub store_timeout_secs: u64,
203 /// Per-op timeout for **merkle batch** chunk store (PUT)
204 /// operations, in seconds. Separate from `store_timeout_secs`
205 /// because merkle PUTs incur the storer-side
206 /// `CLOSENESS_LOOKUP_TIMEOUT` (240 s post-PR #89) on top of the
207 /// usual store path; the client must wait at least that long
208 /// plus padding, or the storer wastes work on a chunk the client
209 /// has already given up on. Default 270 s.
210 pub merkle_store_timeout_secs: u64,
211 /// Per-peer response timeout for chunk GET operations, in seconds.
212 /// This is intentionally independent from `store_timeout_secs`: PUTs
213 /// and GETs have different payload direction and performance profiles.
214 pub chunk_get_timeout_secs: u64,
215 /// Number of closest peers to consider for routing.
216 pub close_group_size: usize,
217 /// **Deprecated.** Pre-adaptive ceiling for quote concurrency.
218 ///
219 /// The adaptive controller now sizes quote fan-out from observed
220 /// signals. This field, when non-zero and smaller than the
221 /// controller's per-channel default, clamps the **quote channel
222 /// only** (it does NOT bleed into store or fetch). Removed in a
223 /// future release.
224 pub quote_concurrency: usize,
225 /// **Deprecated.** Pre-adaptive ceiling for store concurrency.
226 ///
227 /// The adaptive controller now sizes store fan-out from observed
228 /// signals. This field, when non-zero and smaller than the
229 /// controller's per-channel default, clamps the **store channel
230 /// only** (it does NOT bleed into quote or fetch). Removed in a
231 /// future release.
232 pub store_concurrency: usize,
233 /// Adaptive controller configuration. Defaults are tuned to match
234 /// or exceed the prior static behavior — disabling adaptation
235 /// (`adaptive.enabled = false`) reverts to the controller's
236 /// `initial` values without re-evaluation.
237 pub adaptive: AdaptiveConfig,
238 /// Allow loopback (`127.0.0.1`) connections in the saorsa-transport
239 /// layer. Set to `true` only for devnet / local testing. Production
240 /// peers on the public Autonomi network reject the QUIC handshake
241 /// variant produced when this is `true`, so the default is `false`.
242 ///
243 /// This mirrors the `--allow-loopback` flag in `ant-cli`, which already
244 /// defaults to `false` and threads through to the same
245 /// `CoreNodeConfig::builder().local(...)` call.
246 pub allow_loopback: bool,
247 /// Bind a dual-stack IPv6 socket (`true`) or an IPv4-only socket
248 /// (`false`). Defaults to `true`, matching the CLI default.
249 ///
250 /// Set to `false` only when running on hosts without a working IPv6
251 /// stack, to avoid advertising unreachable v6 addresses to the DHT
252 /// (which causes slow connects and junk DHT address records). This
253 /// mirrors the `--ipv4-only` flag in `ant-cli`.
254 pub ipv6: bool,
255}
256
257impl Default for ClientConfig {
258 fn default() -> Self {
259 Self {
260 quote_timeout_secs: DEFAULT_QUOTE_TIMEOUT_SECS,
261 store_timeout_secs: DEFAULT_STORE_TIMEOUT_SECS,
262 merkle_store_timeout_secs: DEFAULT_MERKLE_STORE_TIMEOUT_SECS,
263 chunk_get_timeout_secs: DEFAULT_CHUNK_GET_TIMEOUT_SECS,
264 close_group_size: CLOSE_GROUP_SIZE,
265 quote_concurrency: DEFAULT_QUOTE_CONCURRENCY,
266 store_concurrency: DEFAULT_STORE_CONCURRENCY,
267 adaptive: AdaptiveConfig::default(),
268 allow_loopback: false,
269 ipv6: true,
270 }
271 }
272}
273
274/// Build the adaptive controller for a `Client`. Loads any persisted
275/// snapshot, clamps cold-start values into the deprecated-flag bounds
276/// **per channel** (so a pin on `--store-concurrency` does NOT bleed
277/// into the fetch / quote channels), and returns the persistence path
278/// so callers can save back at shutdown.
279fn build_controller(config: &ClientConfig) -> (AdaptiveController, Option<PathBuf>) {
280 let mut adaptive_cfg = config.adaptive.clone();
281
282 // Per-channel ceilings: each legacy field is interpreted as a cap
283 // for ONLY its matching channel. The fetch channel has no
284 // pre-existing legacy field; it always uses the controller's
285 // default ceiling.
286 //
287 // The legacy fields are non-zero by ClientConfig::default(), but
288 // we honor them as bounds only when they would actually CONSTRAIN
289 // the controller — i.e. when smaller than the per-channel default
290 // max. A default ClientConfig must not silently lower the
291 // controller's ceilings.
292 // A value equal to the historic legacy default is treated as
293 // "not pinned by the user" — without this, every default
294 // ClientConfig would silently lower the controller's per-channel
295 // ceilings to the prior static values (32/8) and the controller
296 // could never grow above them.
297 let user_quote_max = config.quote_concurrency;
298 let user_store_max = config.store_concurrency;
299 let quote_pinned = user_quote_max > 0 && user_quote_max != DEFAULT_QUOTE_CONCURRENCY;
300 let store_pinned = user_store_max > 0 && user_store_max != DEFAULT_STORE_CONCURRENCY;
301 if quote_pinned && user_quote_max < adaptive_cfg.max.quote {
302 adaptive_cfg.max.quote = user_quote_max;
303 }
304 if store_pinned && user_store_max < adaptive_cfg.max.store {
305 adaptive_cfg.max.store = user_store_max;
306 }
307
308 // Cold-start values: matched to the prior static defaults. If the
309 // legacy field caps the channel below the cold-start, lower the
310 // start to match — never start above the channel's max.
311 let mut start = ChannelStart::default();
312 start.quote = start.quote.min(adaptive_cfg.max.quote);
313 start.store = start.store.min(adaptive_cfg.max.store);
314 start.fetch = start.fetch.min(adaptive_cfg.max.fetch);
315
316 let adaptive_enabled = adaptive_cfg.enabled;
317 let controller = AdaptiveController::new(start, adaptive_cfg);
318 // Skip disk warm-start entirely when adaptation is disabled —
319 // fixed-concurrency mode means the user wants exactly the cold
320 // start, no surprises from prior runs. (warm_start is also a
321 // no-op when disabled, but skipping the load avoids file I/O
322 // and the path-resolution side effects.)
323 let persist_path = if adaptive_enabled {
324 let p = adaptive::default_persist_path();
325 if let Some(ref path) = p {
326 if let Some(snap) = adaptive::load_snapshot(path) {
327 debug!(path = %path.display(), "adaptive: warm-start from disk");
328 controller.warm_start(snap);
329 }
330 }
331 p
332 } else {
333 // Even with adaptation off, persist_path is computed so
334 // explicit save_adaptive_snapshot() calls still work — but
335 // the controller currently never moves, so saving the cold
336 // start is harmless.
337 adaptive::default_persist_path()
338 };
339
340 // File downloads choose a stream-decrypt batch size per download
341 // from the current fetch cap and usable RAM, then pass it into
342 // self_encryption's runtime batch-size API. The adaptive controller
343 // still drives fan-out inside each batch by re-reading
344 // `controller.fetch.current()` in the decrypt callback.
345
346 (controller, persist_path)
347}
348
349/// Client for the Autonomi decentralized network.
350///
351/// Provides high-level APIs for storing and retrieving chunks
352/// and files on the network.
353pub struct Client {
354 config: ClientConfig,
355 network: Network,
356 wallet: Option<Arc<Wallet>>,
357 evm_network: Option<ant_protocol::evm::Network>,
358 chunk_cache: ChunkCache,
359 next_request_id: AtomicU64,
360 /// Adaptive concurrency controller: replaces the static
361 /// quote/store concurrency knobs. See `adaptive` module.
362 controller: AdaptiveController,
363 /// Path the controller persists its snapshot to. `None` disables
364 /// persistence (useful for tests / non-disk environments).
365 persist_path: Option<PathBuf>,
366 /// Path for the persistent client peer cache. `None` disables the cache.
367 peer_cache_path: Option<PathBuf>,
368}
369
370impl Client {
371 /// Create a client connected to the given P2P node.
372 #[must_use]
373 pub fn from_node(node: Arc<P2PNode>, config: ClientConfig) -> Self {
374 Self::from_node_with_peer_cache(node, config, None)
375 }
376
377 /// Create a client connected to the given P2P node and attach an optional
378 /// persistent peer cache path.
379 #[must_use]
380 pub fn from_node_with_peer_cache(
381 node: Arc<P2PNode>,
382 config: ClientConfig,
383 peer_cache_path: Option<PathBuf>,
384 ) -> Self {
385 let network = Network::from_node(node);
386 let (controller, persist_path) = build_controller(&config);
387 Self {
388 config,
389 network,
390 wallet: None,
391 evm_network: None,
392 chunk_cache: ChunkCache::default(),
393 next_request_id: AtomicU64::new(1),
394 controller,
395 persist_path,
396 peer_cache_path,
397 }
398 }
399
400 /// Create a client connected to bootstrap peers.
401 ///
402 /// Threads `config.allow_loopback` and `config.ipv6` through to
403 /// `Network::new`, which controls the saorsa-transport `local` and
404 /// `ipv6` flags on the underlying `CoreNodeConfig`. See
405 /// `ClientConfig::allow_loopback` and `ClientConfig::ipv6` for details.
406 ///
407 /// # Errors
408 ///
409 /// Returns an error if the P2P node cannot be created or bootstrapping fails.
410 pub async fn connect(
411 bootstrap_peers: &[std::net::SocketAddr],
412 config: ClientConfig,
413 ) -> Result<Self> {
414 debug!(
415 "Connecting to Autonomi network with {} bootstrap peers (allow_loopback={}, ipv6={})",
416 bootstrap_peers.len(),
417 config.allow_loopback,
418 config.ipv6,
419 );
420 let network = Network::new(bootstrap_peers, config.allow_loopback, config.ipv6).await?;
421 let (controller, persist_path) = build_controller(&config);
422 Ok(Self {
423 config,
424 network,
425 wallet: None,
426 evm_network: None,
427 chunk_cache: ChunkCache::default(),
428 next_request_id: AtomicU64::new(1),
429 controller,
430 persist_path,
431 peer_cache_path: None,
432 })
433 }
434
435 /// Set the wallet for payment operations.
436 ///
437 /// Also populates the EVM network from the wallet so that
438 /// token approvals work without a separate `with_evm_network` call.
439 #[must_use]
440 pub fn with_wallet(mut self, wallet: Wallet) -> Self {
441 self.evm_network = Some(wallet.network().clone());
442 self.wallet = Some(Arc::new(wallet));
443 self
444 }
445
446 /// Set the EVM network without requiring a wallet.
447 ///
448 /// This enables token approval and contract interactions
449 /// for external-signer flows where the private key lives outside Rust.
450 #[must_use]
451 pub fn with_evm_network(mut self, network: ant_protocol::evm::Network) -> Self {
452 self.evm_network = Some(network);
453 self
454 }
455
456 /// Get the EVM network, falling back to the wallet's network if available.
457 ///
458 /// # Errors
459 ///
460 /// Returns an error if neither `with_evm_network` nor `with_wallet` was called.
461 pub(crate) fn require_evm_network(&self) -> Result<&ant_protocol::evm::Network> {
462 if let Some(ref net) = self.evm_network {
463 return Ok(net);
464 }
465 if let Some(ref wallet) = self.wallet {
466 return Ok(wallet.network());
467 }
468 Err(Error::Payment(
469 "EVM network not configured — call with_evm_network() or with_wallet() first"
470 .to_string(),
471 ))
472 }
473
474 /// Get the client configuration.
475 #[must_use]
476 pub fn config(&self) -> &ClientConfig {
477 &self.config
478 }
479
480 /// Get a mutable reference to the client configuration.
481 pub fn config_mut(&mut self) -> &mut ClientConfig {
482 &mut self.config
483 }
484
485 /// Get a reference to the network layer.
486 #[must_use]
487 pub fn network(&self) -> &Network {
488 &self.network
489 }
490
491 /// Get the wallet, if configured.
492 #[must_use]
493 pub fn wallet(&self) -> Option<&Arc<Wallet>> {
494 self.wallet.as_ref()
495 }
496
497 /// Get a reference to the chunk cache.
498 #[must_use]
499 pub fn chunk_cache(&self) -> &ChunkCache {
500 &self.chunk_cache
501 }
502
503 /// Adaptive concurrency controller. Hot loops read
504 /// `controller().<channel>.current()` to size their fan-out and
505 /// call `.observe(...)` on each completion.
506 #[must_use]
507 pub fn controller(&self) -> &AdaptiveController {
508 &self.controller
509 }
510
511 /// Persist the current adaptive snapshot to disk so the next
512 /// `Client::connect` warm-starts at the learned values instead of
513 /// cold defaults. Best effort — failures log and are discarded.
514 /// Idempotent. Safe to call from a Drop impl or an explicit
515 /// shutdown hook.
516 pub fn save_adaptive_snapshot(&self) {
517 if let Some(ref path) = self.persist_path {
518 adaptive::save_snapshot(path, self.controller.snapshot());
519 }
520 }
521
522 /// Persist currently connected peers that have Direct-tagged addresses in
523 /// the DHT. Best effort; failures are logged and do not affect the client
524 /// operation that just completed.
525 pub async fn save_peer_cache(&self) {
526 if let Some(ref path) = self.peer_cache_path {
527 let node = self.network().node();
528 peer_cache::promote_connected_direct_peers(node.as_ref(), path, node.dht().k_value())
529 .await;
530 }
531 }
532
533 /// Get the next request ID for protocol messages.
534 pub(crate) fn next_request_id(&self) -> u64 {
535 self.next_request_id.fetch_add(1, Ordering::Relaxed)
536 }
537
538 /// Return the chunk PUT-target set: the closest [`PUT_TARGET_WIDTH`] peers
539 /// to the address, each paired with its known network addresses.
540 ///
541 /// Used by the merkle store path, which — unlike single-node payment — has
542 /// no witnessed put-target list to forward, so it fetches the closest-K
543 /// neighbourhood locally.
544 pub(crate) async fn put_target_peers(
545 &self,
546 target: &XorName,
547 ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
548 self.closest_peers(target, PUT_TARGET_WIDTH).await
549 }
550
551 /// Return the requested number of closest peers for a target address.
552 ///
553 /// Queries the DHT for peers by XOR distance. Returns each peer
554 /// paired with its known network addresses.
555 pub(crate) async fn closest_peers(
556 &self,
557 target: &XorName,
558 count: usize,
559 ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
560 let peers = self.network().find_closest_peers(target, count).await?;
561
562 if peers.is_empty() {
563 return Err(Error::InsufficientPeers(
564 "DHT returned no peers for target address".to_string(),
565 ));
566 }
567 Ok(peers)
568 }
569}
570
571/// Persist the adaptive snapshot when the `Client` is dropped, so any
572/// caller — CLI, daemon, library user, integration test — gets
573/// warm-start carry-over for free without remembering to call
574/// `save_adaptive_snapshot()` explicitly. Best effort, sync `std::fs`,
575/// no panic risk on a poisoned mutex (the inner helper handles it).
576///
577/// We deliberately write SYNCHRONOUSLY (not via `spawn_blocking`)
578/// because Drop runs during process shutdown / runtime teardown,
579/// when fire-and-forget background tasks can be dropped before they
580/// complete and the snapshot is silently lost. A small synchronous
581/// stall on a tokio worker (typically <1ms for a local-disk JSON
582/// write of ~50 bytes) is the right tradeoff for guaranteed
583/// persistence — BOUNDED by `DROP_SAVE_TIMEOUT` so a stalled
584/// network-mounted data dir cannot block process shutdown.
585const DROP_SAVE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
586
587impl Drop for Client {
588 fn drop(&mut self) {
589 let Some(path) = self.persist_path.clone() else {
590 return;
591 };
592 let snap = self.controller.snapshot();
593 adaptive::save_snapshot_with_timeout(path, snap, DROP_SAVE_TIMEOUT);
594 }
595}
596
597#[cfg(test)]
598#[allow(clippy::unwrap_used)]
599mod tests {
600 use super::*;
601
602 /// Cover EVERY variant of `data::error::Error`. Build an instance of
603 /// each, classify it, and assert the resulting `Outcome` matches the
604 /// only sensible mapping. If a future commit adds a new error variant
605 /// without updating `classify_error`, this test fails to ensure the
606 /// adaptive controller always sees correct capacity signals.
607 ///
608 /// Mapping policy (mirrors `classify_error` doc):
609 /// - `Timeout` -> `Outcome::Timeout`
610 /// - `Network`, `InsufficientPeers`, `Io`, `Protocol`, `Storage`,
611 /// `PartialUpload` -> `Outcome::NetworkError` (transport-related
612 /// or literal capacity failure)
613 /// - everything else -> `Outcome::ApplicationError` (would happen
614 /// on a perfectly healthy network)
615 #[test]
616 fn classify_error_covers_all_variants() {
617 let cases: Vec<(Error, Outcome)> = vec![
618 (Error::Timeout("t".to_string()), Outcome::Timeout),
619 (Error::Network("n".to_string()), Outcome::NetworkError),
620 (
621 Error::InsufficientPeers("p".to_string()),
622 Outcome::NetworkError,
623 ),
624 (Error::Storage("s".to_string()), Outcome::NetworkError),
625 (Error::Payment("p".to_string()), Outcome::ApplicationError),
626 (Error::Protocol("p".to_string()), Outcome::NetworkError),
627 (
628 Error::InvalidData("d".to_string()),
629 Outcome::ApplicationError,
630 ),
631 // A definitively absent record over a working link — the peers
632 // answered, nothing was stored there. Must NOT register as a
633 // capacity signal.
634 (
635 Error::NotFound("missing".to_string()),
636 Outcome::ApplicationError,
637 ),
638 (
639 Error::Serialization("s".to_string()),
640 Outcome::ApplicationError,
641 ),
642 (Error::Crypto("c".to_string()), Outcome::ApplicationError),
643 (
644 Error::Io(std::io::Error::other("io")),
645 Outcome::NetworkError,
646 ),
647 (Error::Config("c".to_string()), Outcome::ApplicationError),
648 (
649 Error::SignatureVerification("s".to_string()),
650 Outcome::ApplicationError,
651 ),
652 (
653 Error::Encryption("e".to_string()),
654 Outcome::ApplicationError,
655 ),
656 (Error::AlreadyStored, Outcome::ApplicationError),
657 (
658 Error::InsufficientDiskSpace("d".to_string()),
659 Outcome::ApplicationError,
660 ),
661 (
662 Error::CostEstimationInconclusive("c".to_string()),
663 Outcome::ApplicationError,
664 ),
665 (
666 Error::PartialUpload {
667 stored: vec![],
668 stored_count: 0,
669 failed: vec![],
670 failed_count: 0,
671 total_chunks: 0,
672 spend: Box::new(crate::data::error::PartialUploadSpend {
673 storage_cost_atto: "0".to_string(),
674 gas_cost_wei: 0,
675 }),
676 reason: "r".to_string(),
677 },
678 Outcome::NetworkError,
679 ),
680 (
681 Error::BadQuoteBinding {
682 peer_id: "peer".to_string(),
683 detail: "mismatch".to_string(),
684 },
685 Outcome::ApplicationError,
686 ),
687 // A remote application rejection: the node responded with a
688 // structured `ProtocolError`, so the transport succeeded and
689 // this must NOT register as a capacity signal (V2-468).
690 (
691 Error::RemotePut {
692 address: "abcd".to_string(),
693 source: ant_protocol::ProtocolError::PaymentFailed("stale quote".to_string()),
694 },
695 Outcome::ApplicationError,
696 ),
697 // A close-group quorum shortfall caused by dial/relay churn with
698 // no PUT-response timeouts — remote peer churn, not local
699 // backpressure, so it must NOT register as a capacity signal
700 // (V2-554). A timeout-bearing shortfall keeps `InsufficientPeers`.
701 (
702 Error::CloseGroupShortfall("Stored on 3 peers, need 4".to_string()),
703 Outcome::ApplicationError,
704 ),
705 ];
706 for (err, expected) in &cases {
707 let got = classify_error(err);
708 assert_eq!(
709 got, *expected,
710 "classify_error({err:?}) = {got:?}, expected {expected:?}",
711 );
712 }
713 }
714
715 /// C4 fix guard: pinning the legacy `quote_concurrency` /
716 /// `store_concurrency` ClientConfig fields must clamp ONLY the
717 /// matching channel's max in the resulting controller. The fetch
718 /// (download) channel must keep its full default ceiling.
719 #[test]
720 fn legacy_concurrency_pin_does_not_bleed_across_channels() {
721 let cfg = ClientConfig {
722 quote_concurrency: 4,
723 store_concurrency: 2,
724 ..ClientConfig::default()
725 };
726 let (controller, _) = build_controller(&cfg);
727 // The store/quote caps must be clamped to the user's pin.
728 assert_eq!(controller.config.max.quote, 4, "quote pin not respected");
729 assert_eq!(controller.config.max.store, 2, "store pin not respected");
730 // The fetch cap must NOT have been lowered — that's the
731 // regression C4 was about.
732 let default_fetch_max = adaptive::ChannelMax::default().fetch;
733 assert_eq!(
734 controller.config.max.fetch, default_fetch_max,
735 "fetch cap was lowered by store/quote pin (C4 regression)"
736 );
737 // Cold-start values must respect the lowered ceilings.
738 assert!(
739 controller.quote.current() <= 4,
740 "quote start exceeds its cap"
741 );
742 assert!(
743 controller.store.current() <= 2,
744 "store start exceeds its cap"
745 );
746 }
747
748 /// Default ClientConfig must NOT silently lower the controller's
749 /// per-channel ceilings — the adaptive defaults give every channel
750 /// real headroom to grow. This guards against future commits
751 /// re-introducing a global clamp.
752 #[test]
753 fn default_client_config_does_not_clamp_controller_max() {
754 let cfg = ClientConfig::default();
755 let (controller, _) = build_controller(&cfg);
756 let defaults = adaptive::ChannelMax::default();
757 // The legacy fields default to 32/8 (the prior static knobs),
758 // both of which are <= the per-channel adaptive defaults
759 // (128/64). build_controller must keep the larger, not clobber
760 // with the legacy values.
761 assert_eq!(controller.config.max.quote, defaults.quote);
762 assert_eq!(controller.config.max.store, defaults.store);
763 assert_eq!(controller.config.max.fetch, defaults.fetch);
764 // Compile-time-ish guard: if a new variant is added to Error,
765 // this match forces an update here.
766 let _ = |e: &Error| match e {
767 Error::Timeout(_)
768 | Error::Network(_)
769 | Error::InsufficientPeers(_)
770 | Error::Storage(_)
771 | Error::Payment(_)
772 | Error::Protocol(_)
773 | Error::InvalidData(_)
774 | Error::NotFound(_)
775 | Error::Serialization(_)
776 | Error::Crypto(_)
777 | Error::Io(_)
778 | Error::Config(_)
779 | Error::SignatureVerification(_)
780 | Error::Encryption(_)
781 | Error::AlreadyStored
782 | Error::InsufficientDiskSpace(_)
783 | Error::CostEstimationInconclusive(_)
784 | Error::Cancelled(_)
785 | Error::PartialUpload { .. }
786 | Error::BadQuoteBinding { .. }
787 | Error::BadQuoteCommitment { .. }
788 | Error::RemotePut { .. }
789 | Error::CloseGroupShortfall(_) => (),
790 };
791 }
792}