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 #[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 detected_reconstruction_api_version: AtomicU32,
54}
55
56impl RemoteClient {
57 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 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 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 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 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 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 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 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 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 #[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)) .body(Body::wrap_stream(upload_stream))
694 }
695
696 #[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 #[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 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 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 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 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 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 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 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!(result.is_ok());
846 }
847}