Skip to main content

xet_client/cas_client/
remote_client.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
3
4use anyhow::anyhow;
5use bytes::Bytes;
6use futures::TryStreamExt;
7use http::HeaderValue;
8use http::header::{CONTENT_LENGTH, HeaderMap, RANGE};
9use reqwest::{Body, Response, StatusCode, Url};
10use reqwest_middleware::ClientWithMiddleware;
11use tracing::{event, info, instrument};
12use xet_core_structures::merklehash::MerkleHash;
13use xet_core_structures::metadata_shard::file_structs::{FileDataSequenceEntry, FileDataSequenceHeader, MDBFileInfo};
14use xet_core_structures::xorb_object::SerializedXorbObject;
15use xet_runtime::core::XetContext;
16
17use super::adaptive_concurrency::{
18    AdaptiveConcurrencyController, ConnectionPermit, download_controller, upload_controller,
19};
20use super::auth::AuthConfig;
21use super::interface::URLProvider;
22use super::progress_tracked_streams::{
23    DownloadProgressStream, ProgressCallback, StreamProgressReporter, UploadProgressStream,
24};
25use super::retry_wrapper::{RetryWrapper, RetryableReqwestError};
26use super::{Client, INFORMATION_LOG_LEVEL};
27use crate::cas_types::{
28    BatchQueryReconstructionResponse, FileChunkHashesResponse, FileRange, HttpRange, Key, QueryReconstructionResponse,
29    QueryReconstructionResponseV2, UploadShardResponse, UploadShardResponseType, UploadXorbResponse,
30    X_RANGE_DIRTY_HEADER,
31};
32use crate::common::http_client::{self, Api};
33use crate::error::{ClientError, Result};
34
35pub const CAS_ENDPOINT: &str = "http://localhost:8080";
36pub const PREFIX_DEFAULT: &str = "default";
37
38static FN_CALL_ID: AtomicU64 = AtomicU64::new(1);
39
40pub struct RemoteClient {
41    pub(crate) ctx: XetContext,
42    endpoint: String,
43    dry_run: bool,
44    http_client: Arc<ClientWithMiddleware>,
45    authenticated_http_client: Arc<ClientWithMiddleware>,
46    /// Authenticated client with no read_timeout, used for shard uploads where server-side
47    /// processing time scales with file entry count and can exceed the global read_timeout.
48    #[cfg(not(target_family = "wasm"))]
49    shard_upload_http_client: Arc<ClientWithMiddleware>,
50    upload_concurrency_controller: Arc<AdaptiveConcurrencyController>,
51    download_concurrency_controller: Arc<AdaptiveConcurrencyController>,
52    /// Caches the discovered reconstruction API version (0 = not yet probed, 1 = V1, 2 = V2).
53    detected_reconstruction_api_version: AtomicU32,
54}
55
56impl RemoteClient {
57    /// Creates a new RemoteClient with an explicit Unix socket path.
58    ///
59    /// # Arguments
60    /// * `endpoint` - The CAS endpoint URL
61    /// * `auth` - Optional authentication configuration
62    /// * `session_id` - Session identifier
63    /// * `dry_run` - Whether to run in dry-run mode
64    /// * `unix_socket_path` - Optional Unix socket path for proxying connections (ignored on non-Unix platforms)
65    /// * `custom_headers` - Optional custom headers to include in HTTP requests (should include User-Agent)
66    pub fn new_with_socket(
67        ctx: XetContext,
68        endpoint: &str,
69        auth: &Option<AuthConfig>,
70        session_id: &str,
71        dry_run: bool,
72        unix_socket_path: Option<&str>,
73        custom_headers: Option<Arc<HeaderMap>>,
74    ) -> Arc<Self> {
75        Arc::new(Self {
76            ctx: ctx.clone(),
77            endpoint: endpoint.to_string(),
78            dry_run,
79            authenticated_http_client: Arc::new(
80                http_client::build_auth_http_client(&ctx, auth, session_id, unix_socket_path, custom_headers.clone())
81                    .unwrap(),
82            ),
83            http_client: Arc::new(
84                http_client::build_http_client(&ctx, session_id, unix_socket_path, custom_headers.clone()).unwrap(),
85            ),
86            #[cfg(not(target_family = "wasm"))]
87            shard_upload_http_client: Arc::new(
88                http_client::build_auth_http_client_no_read_timeout(
89                    &ctx,
90                    auth,
91                    session_id,
92                    unix_socket_path,
93                    custom_headers,
94                )
95                .unwrap(),
96            ),
97            upload_concurrency_controller: upload_controller(&ctx, endpoint),
98            download_concurrency_controller: download_controller(&ctx, endpoint),
99            detected_reconstruction_api_version: AtomicU32::new(0),
100        })
101    }
102
103    /// Creates a new RemoteClient.
104    ///
105    /// If `HF_XET_CLIENT_UNIX_SOCKET_PATH` is set in the configuration, this will
106    /// automatically use the Unix socket for connections (checked by build_http_client).
107    ///
108    /// # Arguments
109    /// * `endpoint` - The CAS endpoint URL
110    /// * `auth` - Optional authentication configuration
111    /// * `session_id` - Session identifier
112    /// * `dry_run` - Whether to run in dry-run mode
113    /// * `custom_headers` - Optional custom headers to include in HTTP requests (should include User-Agent)
114    pub fn new(
115        ctx: XetContext,
116        endpoint: &str,
117        auth: &Option<AuthConfig>,
118        session_id: &str,
119        dry_run: bool,
120        custom_headers: Option<Arc<HeaderMap>>,
121    ) -> Arc<Self> {
122        Self::new_with_socket(ctx, endpoint, auth, session_id, dry_run, None, custom_headers)
123    }
124
125    /// Get the endpoint URL.
126    pub fn endpoint(&self) -> &str {
127        &self.endpoint
128    }
129
130    #[cfg(feature = "simulation")]
131    pub(crate) fn http_client(&self) -> Arc<ClientWithMiddleware> {
132        self.http_client.clone()
133    }
134
135    async fn query_dedup_api(&self, prefix: &str, chunk_hash: &MerkleHash) -> Result<Option<Response>> {
136        // The API endpoint now only supports non-batched dedup request and
137        let key = Key {
138            prefix: prefix.into(),
139            hash: *chunk_hash,
140        };
141
142        let call_id = FN_CALL_ID.fetch_add(1, Ordering::Relaxed);
143        let url = Url::parse(&format!("{}/v1/chunks/{key}", self.endpoint))?;
144        event!(
145            INFORMATION_LOG_LEVEL,
146            call_id,
147            prefix,
148            %chunk_hash,
149            "Starting query_dedup API call",
150        );
151
152        let client = self.authenticated_http_client.clone();
153        let api_tag = "cas::query_dedup";
154
155        let result = RetryWrapper::new(self.ctx.clone(), api_tag)
156            .with_429_no_retry()
157            .with_expected_404()
158            .log_errors_as_info()
159            .run(move || client.get(url.clone()).with_extension(Api(api_tag)).send())
160            .await;
161
162        if result.as_ref().is_err_and(|e| e.status().is_some()) {
163            event!(
164                INFORMATION_LOG_LEVEL,
165                call_id,
166                prefix,
167                %chunk_hash,
168                result="not_found",
169                "Completed query_dedup API call",
170            );
171            return Ok(None);
172        }
173
174        event!(
175            INFORMATION_LOG_LEVEL,
176            call_id,
177            prefix,
178            %chunk_hash,
179            result="found",
180            "Completed query_dedup API call",
181        );
182        Ok(Some(result?))
183    }
184}
185
186impl RemoteClient {
187    async fn get_reconstruction_impl<T>(
188        &self,
189        file_id: &MerkleHash,
190        bytes_range: Option<FileRange>,
191        api_version: &str,
192    ) -> Result<Option<T>>
193    where
194        T: serde::de::DeserializeOwned + 'static,
195    {
196        let call_id = FN_CALL_ID.fetch_add(1, Ordering::Relaxed);
197        let url = Url::parse(&format!("{}/{api_version}/reconstructions/{}", self.endpoint, file_id.hex()))?;
198        let api_tag = match api_version {
199            "v1" => "cas::get_reconstruction_v1",
200            "v2" => "cas::get_reconstruction_v2",
201            _ => {
202                return Err(ClientError::InternalError(anyhow!(
203                    "unsupported reconstruction API version: {api_version}"
204                )));
205            },
206        };
207
208        event!(
209            INFORMATION_LOG_LEVEL,
210            call_id,
211            %file_id,
212            ?bytes_range,
213            api_version,
214            "Starting get_reconstruction API call",
215        );
216
217        let client = self.authenticated_http_client.clone();
218
219        let result: Result<T> = RetryWrapper::new(self.ctx.clone(), api_tag)
220            .with_expected_416()
221            .run_and_extract_json(move || {
222                let mut request = client.get(url.clone()).with_extension(Api(api_tag));
223                if let Some(range) = bytes_range {
224                    request = request.header(RANGE, HttpRange::from(range).range_header())
225                }
226                request.send()
227            })
228            .await;
229
230        match result {
231            Ok(response) => {
232                event!(
233                    INFORMATION_LOG_LEVEL,
234                    call_id,
235                    %file_id,
236                    ?bytes_range,
237                    api_version,
238                    "Completed get_reconstruction API call"
239                );
240                Ok(Some(response))
241            },
242            Err(ClientError::ReqwestError(ref e, _)) if e.status() == Some(StatusCode::RANGE_NOT_SATISFIABLE) => {
243                Ok(None)
244            },
245            Err(e) => Err(e),
246        }
247    }
248
249    /// V1 reconstruction: returns per-range presigned URLs.
250    pub async fn get_reconstruction_v1(
251        &self,
252        file_id: &MerkleHash,
253        bytes_range: Option<FileRange>,
254    ) -> Result<Option<QueryReconstructionResponse>> {
255        self.get_reconstruction_impl(file_id, bytes_range, "v1").await
256    }
257
258    /// V2 reconstruction: returns per-xorb multi-range fetch descriptors.
259    pub async fn get_reconstruction_v2(
260        &self,
261        file_id: &MerkleHash,
262        bytes_range: Option<FileRange>,
263    ) -> Result<Option<QueryReconstructionResponseV2>> {
264        self.get_reconstruction_impl(file_id, bytes_range, "v2").await
265    }
266
267    pub(crate) async fn get_reconstruction_with_version_override(
268        &self,
269        file_id: &MerkleHash,
270        bytes_range: Option<FileRange>,
271        forced_version: Option<u32>,
272    ) -> Result<Option<QueryReconstructionResponseV2>> {
273        // Prefer V2; fall back to V1 on 404/501; persist detected version to
274        // avoid repeated fallback attempts.
275        let version = match forced_version {
276            Some(v) => v,
277            None => {
278                let detected = self.detected_reconstruction_api_version.load(Ordering::Relaxed);
279                if detected != 0 { detected } else { 2 }
280            },
281        };
282
283        match version {
284            2 => match self.get_reconstruction_v2(file_id, bytes_range).await {
285                Ok(result) => {
286                    if forced_version.is_none() {
287                        self.detected_reconstruction_api_version.store(2, Ordering::Relaxed);
288                    }
289                    Ok(result)
290                },
291                Err(e)
292                    if forced_version.is_none()
293                        && matches!(e.status(), Some(StatusCode::NOT_FOUND) | Some(StatusCode::NOT_IMPLEMENTED)) =>
294                {
295                    info!(status = ?e.status(), "V2 reconstruction not available, falling back to V1");
296                    let result = self.get_reconstruction_v1(file_id, bytes_range).await?.map(Into::into);
297                    // Store after success to make sure we don't mess up on e.g. network failure.
298                    self.detected_reconstruction_api_version.store(1, Ordering::Relaxed);
299                    Ok(result)
300                },
301                Err(e) => Err(e),
302            },
303            1 => Ok(self.get_reconstruction_v1(file_id, bytes_range).await?.map(Into::into)),
304            other => Err(ClientError::InternalError(anyhow!("unsupported reconstruction API version: {other}"))),
305        }
306    }
307}
308
309#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
310#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
311impl Client for RemoteClient {
312    async fn get_reconstruction(
313        &self,
314        file_id: &MerkleHash,
315        bytes_range: Option<FileRange>,
316    ) -> Result<Option<QueryReconstructionResponseV2>> {
317        let forced_version = self.ctx.config.client.reconstruction_api_version;
318        self.get_reconstruction_with_version_override(file_id, bytes_range, forced_version)
319            .await
320    }
321
322    async fn batch_get_reconstruction(&self, file_ids: &[MerkleHash]) -> Result<BatchQueryReconstructionResponse> {
323        let mut url_str = format!("{}/reconstructions?", self.endpoint);
324        let mut is_first = true;
325        let mut file_id_list = Vec::new();
326        for hash in file_ids {
327            file_id_list.push(hash.hex());
328            if is_first {
329                is_first = false;
330            } else {
331                url_str.push('&');
332            }
333            url_str.push_str("file_id=");
334            url_str.push_str(hash.hex().as_str());
335        }
336        let url: Url = url_str.parse()?;
337
338        let call_id = FN_CALL_ID.fetch_add(1, Ordering::Relaxed);
339        info!(call_id, file_ids=?file_id_list, "Starting batch_get_reconstruction API call");
340
341        let api_tag = "cas::batch_get_reconstruction";
342        let client = self.authenticated_http_client.clone();
343
344        let response: BatchQueryReconstructionResponse = RetryWrapper::new(self.ctx.clone(), api_tag)
345            .run_and_extract_json(move || client.get(url.clone()).with_extension(Api(api_tag)).send())
346            .await?;
347
348        info!(call_id,
349            file_ids=?file_id_list,
350            response_count=response.files.len(),
351            "Completed batch_get_reconstruction API call",
352        );
353
354        Ok(response)
355    }
356
357    async fn acquire_download_permit(&self) -> Result<ConnectionPermit> {
358        self.download_concurrency_controller.acquire_connection_permit().await
359    }
360
361    async fn get_file_term_data(
362        &self,
363        url_info: Box<dyn URLProvider>,
364        download_permit: ConnectionPermit,
365        progress_callback: Option<ProgressCallback>,
366        uncompressed_size_if_known: Option<usize>,
367    ) -> Result<(Bytes, Vec<u32>)> {
368        let api_tag = "s3::get_range";
369        let http_client = self.http_client.clone();
370        let url_info = Arc::new(url_info);
371
372        let (_, url_ranges) = url_info.retrieve_url().await?;
373        let total_download_bytes: u64 = url_ranges.iter().map(|r| r.length()).sum();
374
375        let mut transfer_reporter = StreamProgressReporter::new(total_download_bytes)
376            .with_adaptive_concurrency_reporter(download_permit.get_partial_completion_reporting_function());
377        if let Some(cb) = progress_callback {
378            transfer_reporter = transfer_reporter.with_progress_callback(cb);
379        }
380
381        let result = RetryWrapper::new(self.ctx.clone(), api_tag)
382            .with_retry_on_403()
383            .with_connection_permit(download_permit, None)
384            .run_and_extract_custom(
385                move || {
386                    let http_client = http_client.clone();
387                    let url_info = url_info.clone();
388
389                    async move {
390                        let (url_string, url_ranges) = url_info
391                            .retrieve_url()
392                            .await
393                            .map_err(|e| reqwest_middleware::Error::Middleware(e.into()))?;
394                        let url =
395                            Url::parse(&url_string).map_err(|e| reqwest_middleware::Error::Middleware(e.into()))?;
396
397                        // RFC 7233 ยง2.1: single-range uses "bytes=S-E", multi-range uses "bytes=S1-E1,S2-E2,..."
398                        let range_header_value = if url_ranges.len() == 1 {
399                            url_ranges[0].range_header()
400                        } else {
401                            let joined = url_ranges
402                                .iter()
403                                .map(|r| format!("{}-{}", r.start, r.end))
404                                .collect::<Vec<_>>()
405                                .join(",");
406                            format!("bytes={joined}")
407                        };
408
409                        let response = http_client
410                            .get(url)
411                            .header(RANGE, range_header_value)
412                            .with_extension(Api(api_tag))
413                            .send()
414                            .await?;
415
416                        if response.status() == reqwest::StatusCode::FORBIDDEN {
417                            url_info
418                                .refresh_url()
419                                .await
420                                .map_err(|e| reqwest_middleware::Error::Middleware(e.into()))?;
421                        }
422
423                        Ok(response)
424                    }
425                },
426                move |resp: Response| {
427                    let transfer_reporter = transfer_reporter.clone();
428                    async move {
429                        let content_type = resp
430                            .headers()
431                            .get("content-type")
432                            .and_then(|v| v.to_str().ok())
433                            .unwrap_or("")
434                            .to_string();
435
436                        let is_multipart = content_type.contains("multipart/byteranges");
437
438                        if is_multipart {
439                            let body = resp
440                                .bytes()
441                                .await
442                                .map_err(|e| RetryableReqwestError::RetryableError(ClientError::from(e)))?;
443
444                            let multipart_parts = crate::cas_client::multipart::parse_multipart_byteranges(&content_type, body)
445                                .map_err(RetryableReqwestError::FatalError)?;
446
447                            let mut all_decompressed = Vec::with_capacity(uncompressed_size_if_known.unwrap_or(0));
448                            let mut all_chunk_indices = Vec::<u32>::new();
449                            let mut total_compressed_bytes = 0u64;
450
451                            for part in multipart_parts {
452                                total_compressed_bytes += part.data.len() as u64;
453
454                                let (data, chunk_indices) =
455                                    xet_core_structures::xorb_object::deserialize_chunks(&mut std::io::Cursor::new(part.data.as_ref()))
456                                        .map_err(|e| {
457                                            RetryableReqwestError::RetryableError(ClientError::FormatError(e))
458                                        })?;
459
460                                xet_core_structures::xorb_object::append_chunk_segment(
461                                    &mut all_decompressed,
462                                    &mut all_chunk_indices,
463                                    &data,
464                                    &chunk_indices,
465                                );
466
467                                transfer_reporter.report_progress(total_compressed_bytes as usize);
468                            }
469
470                            if let Some(expected) = uncompressed_size_if_known
471                                && expected != all_decompressed.len()
472                            {
473                                return Err(RetryableReqwestError::RetryableError(ClientError::Other(format!(
474                                    "get_file_term_data: expected {expected} uncompressed bytes, got {}",
475                                    all_decompressed.len()
476                                ))));
477                            }
478                            Ok((Bytes::from(all_decompressed), all_chunk_indices))
479                        } else {
480                            let incoming_stream = DownloadProgressStream::wrap_stream(
481                                resp.bytes_stream().map_err(std::io::Error::other),
482                                transfer_reporter,
483                            );
484
485                            let capacity = uncompressed_size_if_known.unwrap_or(0);
486                            let mut buffer = Vec::with_capacity(capacity);
487                            let mut writer = std::io::Cursor::new(&mut buffer);
488
489                            let result = xet_core_structures::xorb_object::deserialize_async::deserialize_chunks_to_writer_from_stream(
490                                incoming_stream,
491                                &mut writer,
492                            )
493                            .await;
494
495                            match result {
496                                Ok((_compressed_len, chunk_byte_indices)) => {
497                                    if let Some(expected) = uncompressed_size_if_known
498                                        && expected != buffer.len()
499                                    {
500                                        return Err(RetryableReqwestError::RetryableError(ClientError::Other(format!(
501                                            "get_file_term_data: expected {expected} uncompressed bytes, got {}",
502                                            buffer.len()
503                                        ))));
504                                    }
505                                    Ok((Bytes::from(buffer), chunk_byte_indices))
506                                },
507                                Err(e) => Err(RetryableReqwestError::RetryableError(ClientError::FormatError(e))),
508                            }
509                        }
510                    }
511                },
512            )
513            .await?;
514
515        Ok(result)
516    }
517
518    #[instrument(skip_all, name = "RemoteClient::get_file_reconstruction", fields(file.hash = file_hash.hex()
519    ))]
520    async fn get_file_reconstruction_info(
521        &self,
522        file_hash: &MerkleHash,
523    ) -> Result<Option<(MDBFileInfo, Option<MerkleHash>)>> {
524        let call_id = FN_CALL_ID.fetch_add(1, Ordering::Relaxed);
525        let url = Url::parse(&format!("{}/v1/reconstructions/{}", self.endpoint, file_hash.hex()))?;
526        event!(INFORMATION_LOG_LEVEL, call_id, %file_hash, "Starting get_file_reconstruction_info API call");
527
528        let api_tag = "cas::get_reconstruction_info";
529        let client = self.authenticated_http_client.clone();
530
531        let response: QueryReconstructionResponse = RetryWrapper::new(self.ctx.clone(), api_tag)
532            .run_and_extract_json(move || client.get(url.clone()).with_extension(Api(api_tag)).send())
533            .await?;
534
535        let terms_count = response.terms.len();
536        let result = Some((
537            MDBFileInfo {
538                metadata: FileDataSequenceHeader::new(*file_hash, terms_count, false, false),
539                segments: response
540                    .terms
541                    .into_iter()
542                    .map(|ce| {
543                        FileDataSequenceEntry::new(ce.hash.into(), ce.unpacked_length, ce.range.start, ce.range.end)
544                    })
545                    .collect(),
546                verification: vec![],
547                metadata_ext: None,
548            },
549            None,
550        ));
551
552        event!(INFORMATION_LOG_LEVEL, call_id, %file_hash, terms_count, "Completed get_file_reconstruction_info API call");
553
554        Ok(result)
555    }
556
557    async fn query_for_global_dedup_shard(&self, prefix: &str, chunk_hash: &MerkleHash) -> Result<Option<Bytes>> {
558        let Some(response) = self.query_dedup_api(prefix, chunk_hash).await? else {
559            return Ok(None);
560        };
561
562        Ok(Some(response.bytes().await?))
563    }
564
565    async fn acquire_upload_permit(&self) -> Result<ConnectionPermit> {
566        self.upload_concurrency_controller.acquire_connection_permit().await
567    }
568
569    #[instrument(skip_all, name = "RemoteClient::upload_shard", fields(shard.len = shard_data.len()))]
570    async fn upload_shard(&self, shard_data: Bytes, upload_permit: ConnectionPermit) -> Result<bool> {
571        if self.dry_run {
572            return Ok(true);
573        }
574
575        let call_id = FN_CALL_ID.fetch_add(1, Ordering::Relaxed);
576        let n_upload_bytes = shard_data.len();
577        event!(INFORMATION_LOG_LEVEL, call_id, size = n_upload_bytes, "Starting upload_shard API call",);
578
579        let api_tag = "cas::upload_shard";
580        let url = Url::parse(&format!("{}/v1/shards", self.endpoint))?;
581
582        // Use the no-read-timeout client for shard uploads. reqwest's per-request timeout()
583        // does NOT override the client-level read_timeout(), so we use a separate client
584        // with no read_timeout. Server-side shard processing scales linearly with file entry
585        // count and can exceed the global read_timeout (300s) for large shards.
586        #[cfg(not(target_family = "wasm"))]
587        let client = self.shard_upload_http_client.clone();
588
589        #[cfg(target_family = "wasm")]
590        let client = self.authenticated_http_client.clone();
591
592        let response: UploadShardResponse = RetryWrapper::new(self.ctx.clone(), api_tag)
593            .with_connection_permit(upload_permit, Some(shard_data.len() as u64))
594            .run_and_extract_json(move || {
595                client
596                    .post(url.clone())
597                    .with_extension(Api(api_tag))
598                    .body(shard_data.clone())
599                    .send()
600            })
601            .await?;
602
603        match response.result {
604            UploadShardResponseType::Exists => {
605                event!(
606                    INFORMATION_LOG_LEVEL,
607                    call_id,
608                    size = n_upload_bytes,
609                    result = "exists",
610                    "Completed upload_shard API call",
611                );
612                Ok(false)
613            },
614            UploadShardResponseType::SyncPerformed => {
615                event!(
616                    INFORMATION_LOG_LEVEL,
617                    call_id,
618                    size = n_upload_bytes,
619                    result = "sync performed",
620                    "Completed upload_shard API call",
621                );
622                Ok(true)
623            },
624        }
625    }
626
627    #[instrument(skip_all, name = "RemoteClient::upload_xorb", fields(key = Key{prefix : prefix.to_string(), hash : serialized_xorb_object.hash}.to_string(),
628                 xorb.len = serialized_xorb_object.serialized_data.len(), xorb.num_chunks = serialized_xorb_object.num_chunks
629    ))]
630    async fn upload_xorb(
631        &self,
632        prefix: &str,
633        serialized_xorb_object: SerializedXorbObject,
634        progress_callback: Option<ProgressCallback>,
635        upload_permit: ConnectionPermit,
636    ) -> Result<u64> {
637        let key = Key {
638            prefix: prefix.to_string(),
639            hash: serialized_xorb_object.hash,
640        };
641
642        let call_id = FN_CALL_ID.fetch_add(1, Ordering::Relaxed);
643        let url = Url::parse(&format!("{}/v1/xorbs/{key}", self.endpoint))?;
644
645        let n_upload_bytes = serialized_xorb_object.serialized_data.len() as u64;
646        event!(
647            INFORMATION_LOG_LEVEL,
648            call_id,
649            prefix,
650            hash=%serialized_xorb_object.hash,
651            size=n_upload_bytes,
652            num_chunks=serialized_xorb_object.num_chunks,
653            "Starting upload_xorb API call",
654        );
655
656        let n_transfer_bytes = serialized_xorb_object.serialized_data.len() as u64;
657
658        let serialized_data = serialized_xorb_object.serialized_data.clone();
659
660        #[cfg(not(target_family = "wasm"))]
661        let block_size = self.ctx.config.client.upload_reporting_block_size;
662
663        let mut upload_reporter = StreamProgressReporter::new(n_transfer_bytes)
664            .with_adaptive_concurrency_reporter(upload_permit.get_partial_completion_reporting_function());
665        if let Some(cb) = progress_callback {
666            upload_reporter = upload_reporter.with_progress_callback(cb);
667        }
668
669        let xorb_uploaded = {
670            if !self.dry_run {
671                let client = self.authenticated_http_client.clone();
672
673                let api_tag = "cas::upload_xorb";
674
675                let response: UploadXorbResponse = RetryWrapper::new(self.ctx.clone(), api_tag)
676                    .with_connection_permit(upload_permit, Some(n_transfer_bytes))
677                    .run_and_extract_json(move || {
678                        let url = url.clone();
679                        let serialized_data = serialized_data.clone();
680
681                        let request = {
682                            #[cfg(not(target_family = "wasm"))]
683                            {
684                                let upload_stream = UploadProgressStream::wrap_bytes_as_stream(
685                                    serialized_data,
686                                    block_size,
687                                    upload_reporter.clone(),
688                                );
689                                client
690                                    .post(url)
691                                    .with_extension(Api(api_tag))
692                                    .header(CONTENT_LENGTH, HeaderValue::from(n_upload_bytes)) // must be set because of streaming
693                                    .body(Body::wrap_stream(upload_stream))
694                            }
695
696                            // reqwest's wasm backend does not support streaming request bodies;
697                            // pass the raw Bytes directly (CONTENT_LENGTH is set by reqwest from the body length).
698                            #[cfg(target_family = "wasm")]
699                            {
700                                client.post(url).with_extension(Api(api_tag)).body(serialized_data)
701                            }
702                        };
703
704                        request.send()
705                    })
706                    .await?;
707
708                // Wasm has no per-chunk progress hook (no streaming body); emit one bulk
709                // event after success so the user callback and adaptive-concurrency
710                // reporter both observe the full byte count.
711                #[cfg(target_family = "wasm")]
712                upload_reporter.report_progress(n_transfer_bytes as usize);
713
714                response.was_inserted
715            } else {
716                true
717            }
718        };
719
720        if !xorb_uploaded {
721            event!(
722                INFORMATION_LOG_LEVEL,
723                call_id,
724                prefix,
725                hash=%serialized_xorb_object.hash,
726                result="not_inserted",
727                "Completed upload_xorb API call",
728            );
729        } else {
730            event!(
731                INFORMATION_LOG_LEVEL,
732                call_id,
733                prefix,
734                hash=%serialized_xorb_object.hash,
735                size=n_upload_bytes,
736                result="inserted",
737                "Completed upload_xorb API call",
738            );
739        }
740
741        Ok(n_upload_bytes)
742    }
743
744    #[instrument(skip_all, name = "RemoteClient::get_file_chunk_hashes", fields(file.hash = file_id.hex(), n_ranges = dirty_ranges.len()))]
745    async fn get_file_chunk_hashes(
746        &self,
747        file_id: &MerkleHash,
748        dirty_ranges: Vec<FileRange>,
749    ) -> Result<FileChunkHashesResponse> {
750        if dirty_ranges.is_empty() {
751            return Err(ClientError::Other("get_file_chunk_hashes requires at least one dirty range".into()));
752        }
753
754        let url = Url::parse(&format!("{}/v2/file-chunk-hashes/{}", self.endpoint, file_id.hex()))?;
755
756        // Multi-range `bytes=A-B,C-D` value. `HttpRange` is inclusive-end and `Display`s as
757        // `start-end`; conversion from `FileRange` does the +1/-1 for us.
758        let header_value = HeaderValue::from_str(&format!(
759            "bytes={}",
760            dirty_ranges
761                .iter()
762                .copied()
763                .map(HttpRange::from)
764                .map(|r| r.to_string())
765                .collect::<Vec<_>>()
766                .join(",")
767        ))
768        .map_err(|err| ClientError::Other(format!("invalid X-Range-Dirty header value: {err}")))?;
769
770        let api_tag = "cas::get_file_chunk_hashes";
771        let client = self.authenticated_http_client.clone();
772
773        let response: FileChunkHashesResponse = RetryWrapper::new(self.ctx.clone(), api_tag)
774            .run_and_extract_json(move || {
775                client
776                    .get(url.clone())
777                    .header(X_RANGE_DIRTY_HEADER, header_value.clone())
778                    .with_extension(Api(api_tag))
779                    .send()
780            })
781            .await?;
782
783        Ok(response)
784    }
785}
786
787#[cfg(test)]
788#[cfg(not(target_family = "wasm"))]
789mod tests {
790    use tracing_test::traced_test;
791    use xet_core_structures::xorb_object::CompressionScheme;
792    use xet_core_structures::xorb_object::xorb_format_test_utils::{
793        ChunkSize, build_and_verify_xorb_object, build_raw_xorb,
794    };
795
796    use super::*;
797
798    #[test]
799    fn test_clients_share_controllers_per_ctx_and_endpoint() {
800        let ctx = XetContext::default().unwrap();
801        let c1 = RemoteClient::new(ctx.clone(), "https://cas-a.example.com", &None, "", false, None);
802        let c2 = RemoteClient::new(ctx.clone(), "https://cas-a.example.com", &None, "", false, None);
803
804        // Same ctx + same endpoint: shared upload and download controllers.
805        assert!(Arc::ptr_eq(&c1.upload_concurrency_controller, &c2.upload_concurrency_controller));
806        assert!(Arc::ptr_eq(&c1.download_concurrency_controller, &c2.download_concurrency_controller));
807
808        // Same ctx, different endpoint: independent controllers.
809        let c3 = RemoteClient::new(ctx.clone(), "https://cas-b.example.com", &None, "", false, None);
810        assert!(!Arc::ptr_eq(&c1.upload_concurrency_controller, &c3.upload_concurrency_controller));
811
812        // Creating a second endpoint must not evict the first: re-fetching cas-a still shares with c1.
813        let c5 = RemoteClient::new(ctx.clone(), "https://cas-a.example.com", &None, "", false, None);
814        assert!(Arc::ptr_eq(&c1.upload_concurrency_controller, &c5.upload_concurrency_controller));
815
816        // Different ctx (different session), same endpoint: independent controllers.
817        let ctx2 = XetContext::default().unwrap();
818        let c4 = RemoteClient::new(ctx2, "https://cas-a.example.com", &None, "", false, None);
819        assert!(!Arc::ptr_eq(&c1.upload_concurrency_controller, &c4.upload_concurrency_controller));
820    }
821
822    #[ignore = "requires a running CAS server"]
823    #[traced_test]
824    #[test]
825    fn test_basic_put() {
826        // Arrange
827        let prefix = PREFIX_DEFAULT;
828        let raw_xorb = build_raw_xorb(3, ChunkSize::Random(512, 10248));
829
830        let ctx = XetContext::default().unwrap();
831        let client = RemoteClient::new(ctx.clone(), CAS_ENDPOINT, &None, "", false, None);
832
833        let xorb_obj = build_and_verify_xorb_object(raw_xorb, CompressionScheme::LZ4);
834
835        // Act
836        let result = ctx
837            .runtime
838            .bridge_sync(async move {
839                let permit = client.acquire_upload_permit().await.unwrap();
840                client.upload_xorb(prefix, xorb_obj, None, permit).await
841            })
842            .unwrap();
843
844        // Assert
845        assert!(result.is_ok());
846    }
847}