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