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 #[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 detected_reconstruction_api_version: AtomicU32,
57 detected_shard_api_version: AtomicU32,
59}
60
61impl RemoteClient {
62 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 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 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 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 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 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 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 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 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 let client = self.authenticated_http_client.clone();
385
386 let block_size = self.ctx.config.client.upload_reporting_block_size;
387
388 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 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)) .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 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 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 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 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)) .body(Body::wrap_stream(upload_stream))
848 }
849
850 #[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 #[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 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 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 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 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 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 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 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!(result.is_ok());
1000 }
1001}