···6161 /// The compressed size of the NAR, in bytes.
6262 #[serde(skip_serializing_if = "Option::is_none")]
6363 pub file_size: Option<usize>,
6464+6565+ /// The fraction of data that was deduplicated, from 0 to 1.
6666+ pub frac_deduplicated: Option<f64>,
6467}
65686669#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
6770#[non_exhaustive]
6871pub enum UploadPathResultKind {
6972 /// The path was uploaded.
7373+ ///
7474+ /// This is purely informational and servers may return
7575+ /// this variant even when the NAR is deduplicated.
7076 Uploaded,
71777278 /// The path was globally deduplicated.
7979+ ///
8080+ /// The exact semantics of what counts as deduplicated
8181+ /// is opaque to the client.
7382 Deduplicated,
7483}
7584
+31-29
client/src/command/push.rs
···1717use crate::cache::{CacheName, CacheRef};
1818use crate::cli::Opts;
1919use crate::config::Config;
2020-use attic::api::v1::upload_path::{UploadPathNarInfo, UploadPathResultKind};
2020+use attic::api::v1::upload_path::{UploadPathNarInfo, UploadPathResult, UploadPathResultKind};
2121use attic::error::AtticResult;
2222use attic::nix_store::{NixStore, StorePath, StorePathHash, ValidPathInfo};
2323···132132 let start = Instant::now();
133133 match api.upload_path(upload_info, nar_stream).await {
134134 Ok(r) => {
135135- if r.is_none() {
136136- mp.suspend(|| {
137137- eprintln!("Warning: Please update your server. Compatibility will be removed in the first stable release.");
138138- })
139139- }
135135+ let r = r.unwrap_or(UploadPathResult {
136136+ kind: UploadPathResultKind::Uploaded,
137137+ file_size: None,
138138+ frac_deduplicated: None,
139139+ });
140140141141- let deduplicated = if let Some(r) = r {
142142- r.kind == UploadPathResultKind::Deduplicated
143143- } else {
144144- false
145145- };
141141+ let info_string: String = match r.kind {
142142+ UploadPathResultKind::Deduplicated => "deduplicated".to_string(),
143143+ _ => {
144144+ let elapsed = start.elapsed();
145145+ let seconds = elapsed.as_secs_f64();
146146+ let speed = (path_info.nar_size as f64 / seconds) as u64;
146147147147- if deduplicated {
148148- mp.suspend(|| {
149149- eprintln!("✅ {} (deduplicated)", path.as_os_str().to_string_lossy());
150150- });
151151- bar.finish_and_clear();
152152- } else {
153153- let elapsed = start.elapsed();
154154- let seconds = elapsed.as_secs_f64();
155155- let speed = (path_info.nar_size as f64 / seconds) as u64;
148148+ let mut s = format!("{}/s", HumanBytes(speed));
149149+150150+ if let Some(frac_deduplicated) = r.frac_deduplicated {
151151+ if frac_deduplicated > 0.01f64 {
152152+ s += &format!(", {:.1}% deduplicated", frac_deduplicated * 100.0);
153153+ }
154154+ }
155155+156156+ s
157157+ }
158158+ };
156159157157- mp.suspend(|| {
158158- eprintln!(
159159- "✅ {} ({}/s)",
160160- path.as_os_str().to_string_lossy(),
161161- HumanBytes(speed)
162162- );
163163- });
164164- bar.finish_and_clear();
165165- }
160160+ mp.suspend(|| {
161161+ eprintln!(
162162+ "✅ {} ({})",
163163+ path.as_os_str().to_string_lossy(),
164164+ info_string
165165+ );
166166+ });
167167+ bar.finish_and_clear();
166168167169 Ok(())
168170 }
+40-13
server/src/api/v1/upload_path.rs
···6262 Stream(Box<dyn AsyncRead + Send + Unpin + 'static>, Hash, usize),
6363}
64646565+/// Result of a chunk upload.
6666+struct UploadChunkResult {
6767+ guard: ChunkGuard,
6868+ deduplicated: bool,
6969+}
7070+6571/// Applies compression to a stream, computing hashes along the way.
6672///
6773/// Our strategy is to stream directly onto a UUID-keyed file on the
···247253 Ok(Json(UploadPathResult {
248254 kind: UploadPathResultKind::Deduplicated,
249255 file_size: None, // TODO: Sum the chunks
256256+ frac_deduplicated: None,
250257 }))
251258}
252259···341348 }
342349343350 // Wait for all uploads to complete
344344- let chunks: Vec<ChunkGuard> = join_all(futures)
351351+ let chunks: Vec<UploadChunkResult> = join_all(futures)
345352 .await
346353 .into_iter()
347354 .map(|join_result| join_result.unwrap())
348355 .collect::<ServerResult<Vec<_>>>()?;
349356350350- let file_size = chunks
351351- .iter()
352352- .fold(0, |acc, c| acc + c.file_size.unwrap() as usize);
357357+ let (file_size, deduplicated_size) =
358358+ chunks
359359+ .iter()
360360+ .fold((0, 0), |(file_size, deduplicated_size), c| {
361361+ (
362362+ file_size + c.guard.file_size.unwrap() as usize,
363363+ if c.deduplicated {
364364+ deduplicated_size + c.guard.chunk_size as usize
365365+ } else {
366366+ deduplicated_size
367367+ },
368368+ )
369369+ });
353370354371 // Finally...
355372 let txn = database
···385402 ChunkRef::insert(chunkref::ActiveModel {
386403 nar_id: Set(nar_id),
387404 seq: Set(i as i32),
388388- chunk_id: Set(Some(chunk.id)),
389389- chunk_hash: Set(chunk.chunk_hash.clone()),
390390- compression: Set(chunk.compression.clone()),
405405+ chunk_id: Set(Some(chunk.guard.id)),
406406+ chunk_hash: Set(chunk.guard.chunk_hash.clone()),
407407+ compression: Set(chunk.guard.compression.clone()),
391408 ..Default::default()
392409 })
393410 .exec(&txn)
···419436 Ok(Json(UploadPathResult {
420437 kind: UploadPathResultKind::Uploaded,
421438 file_size: Some(file_size),
439439+440440+ // Currently, frac_deduplicated is computed from size before compression
441441+ frac_deduplicated: Some(deduplicated_size as f64 / *nar_size as f64),
422442 }))
423443}
424444···452472 state.config.require_proof_of_possession,
453473 )
454474 .await?;
455455- let file_size = chunk.file_size.unwrap() as usize;
475475+ let file_size = chunk.guard.file_size.unwrap() as usize;
456476457477 // Finally...
458478 let txn = database
···467487 compression: Set(compression.to_string()),
468488469489 nar_hash: Set(upload_info.nar_hash.to_typed_base16()),
470470- nar_size: Set(chunk.chunk_size),
490490+ nar_size: Set(chunk.guard.chunk_size),
471491472492 num_chunks: Set(1),
473493···487507 ChunkRef::insert(chunkref::ActiveModel {
488508 nar_id: Set(nar_id),
489509 seq: Set(0),
490490- chunk_id: Set(Some(chunk.id)),
510510+ chunk_id: Set(Some(chunk.guard.id)),
491511 chunk_hash: Set(upload_info.nar_hash.to_typed_base16()),
492512 compression: Set(compression.to_string()),
493513 ..Default::default()
···520540 Ok(Json(UploadPathResult {
521541 kind: UploadPathResultKind::Uploaded,
522542 file_size: Some(file_size),
543543+ frac_deduplicated: None,
523544 }))
524545}
525546···533554 database: DatabaseConnection,
534555 state: State,
535556 require_proof_of_possession: bool,
536536-) -> ServerResult<ChunkGuard> {
557557+) -> ServerResult<UploadChunkResult> {
537558 let compression: Compression = compression_type.into();
538559539560 let given_chunk_hash = data.hash();
···565586 }
566587 }
567588568568- return Ok(existing_chunk);
589589+ return Ok(UploadChunkResult {
590590+ guard: existing_chunk,
591591+ deduplicated: true,
592592+ });
569593 }
570594571595 let key = format!("{}.chunk", Uuid::new_v4());
···680704681705 let guard = ChunkGuard::from_locked(database.clone(), chunk);
682706683683- Ok(guard)
707707+ Ok(UploadChunkResult {
708708+ guard,
709709+ deduplicated: false,
710710+ })
684711}
685712686713/// Returns a compressor function that takes some stream as input.