Skip to content

Commit 9eabaaa

Browse files
perf(snapshots): Skip uploading images that already exist in objectstore (#3305)
Uses the new batch HEAD operation added in [objectstore#478](getsentry/objectstore#478) to check which images already exist before uploading. Since image keys are content-addressed (SHA-256 hash), unchanged images between runs produce the same key and don't need re-uploading. **How it works:** - After hashing all images, batch HEAD all keys via `session.many()` to check existence - Only batch PUT images where HEAD returned 404 (not found) - HEAD errors are treated as "doesn't exist" — safe fallback that just re-uploads **Performance with 40k images:** - First upload (all new): ~33 minutes - Second upload (all cached): ~4.5 minutes (~7.5x faster) Bumps `objectstore-client` from 0.1.6 to 0.1.9 and adds `futures-util` for `StreamExt::next()` on the HEAD results stream. Note: the objectstore expiration policy is still TTL. Once it's changed to TTI, the HEAD requests will also bump expiration timers, preventing frequently-used images from expiring.
1 parent 2624b8d commit 9eabaaa

4 files changed

Lines changed: 70 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Performance
6+
7+
- (snapshots) Skip uploading images that already exist in objectstore by batch-checking with HEAD requests first ([#3305](https://github.com/getsentry/sentry-cli/pull/3305))
8+
59
### Fixes
610

711
- (snapshots) Reject snapshot uploads that have a PR number but no base SHA, since comparisons cannot work without a base reference ([#3300](https://github.com/getsentry/sentry-cli/pull/3300))

Cargo.lock

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ elementtree = "1.2.3"
3131
flate2 = { version = "1.0.25", default-features = false, features = [
3232
"rust_backend",
3333
] }
34+
futures-util = "0.3"
3435
git2 = { version = "0.20.4", default-features = false }
3536
glob = "0.3.1"
3637
http = "1.4.0"
@@ -44,7 +45,7 @@ java-properties = "2.0.0"
4445
lazy_static = "1.4.0"
4546
libc = "0.2.139"
4647
log = { version = "0.4.17", features = ["std"] }
47-
objectstore-client = { version = "0.1.6" , default-features = false, features = ["native-tls"] }
48+
objectstore-client = { version = "0.1.9" , default-features = false, features = ["native-tls"] }
4849
open = "3.2.0"
4950
parking_lot = "0.12.1"
5051
percent-encoding = "2.2.0"

src/commands/build/snapshots.rs

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashMap;
1+
use std::collections::{HashMap, HashSet};
22
use std::fs::File;
33
use std::io::BufReader;
44
use std::path::{Path, PathBuf};
@@ -8,9 +8,10 @@ use std::time::Duration;
88
use anyhow::{Context as _, Result};
99
use clap::{Arg, ArgMatches, Command};
1010
use console::style;
11+
use futures_util::StreamExt as _;
1112
use itertools::Itertools as _;
1213
use log::{debug, warn};
13-
use objectstore_client::{ClientBuilder, ExpirationPolicy, Usecase};
14+
use objectstore_client::{ClientBuilder, ExpirationPolicy, OperationResult, Usecase};
1415
use rayon::prelude::*;
1516
use secrecy::ExposeSecret as _;
1617
use serde_json::Value;
@@ -146,9 +147,8 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
146147

147148
validate_image_sizes(&images)?;
148149

149-
// Upload image files to objectstore
150150
println!(
151-
"{} Uploading {} image {}",
151+
"{} Processing {} image {}",
152152
style(">").dim(),
153153
style(images.len()).yellow(),
154154
if images.len() == 1 { "file" } else { "files" }
@@ -416,33 +416,69 @@ fn upload_images(
416416

417417
let total_count = uploads.len();
418418

419-
let mut many_builder = session.many();
420-
for prepared in uploads {
421-
many_builder = many_builder.push(
422-
session
423-
.put_path(prepared.path.clone())
424-
.key(&prepared.key)
425-
.expiration_policy(expiration),
419+
let existing_keys: HashSet<String> = runtime.block_on(async {
420+
let mut head_builder = session.many();
421+
for prepared in &uploads {
422+
head_builder = head_builder.push(session.head(&prepared.key));
423+
}
424+
425+
let mut results = head_builder.send().await;
426+
let mut existing = HashSet::new();
427+
while let Some(result) = results.next().await {
428+
if let OperationResult::Head(key, Ok(Some(_))) = result {
429+
existing.insert(key);
430+
}
431+
}
432+
existing
433+
});
434+
435+
let missing_uploads: Vec<_> = uploads
436+
.into_iter()
437+
.filter(|p| !existing_keys.contains(&p.key))
438+
.collect();
439+
let skipped = total_count - missing_uploads.len();
440+
let upload_count = missing_uploads.len();
441+
442+
if skipped > 0 {
443+
println!(
444+
"{} {} of {total_count} {} already uploaded, uploading {} new",
445+
style(">").dim(),
446+
style(skipped).yellow(),
447+
if total_count == 1 { "image" } else { "images" },
448+
style(upload_count).yellow(),
426449
);
427450
}
428451

429-
let result = runtime.block_on(async { many_builder.send().await.error_for_failures().await });
430-
if let Err(errors) = result {
431-
let errors: Vec<_> = errors.collect();
432-
let error_count = errors.len();
433-
eprintln!("There were errors uploading images:");
434-
for error in errors {
435-
let error = anyhow::Error::new(error);
436-
eprintln!(" {}", style(format!("{error:#}")).red());
452+
if upload_count > 0 {
453+
let mut many_builder = session.many();
454+
for prepared in missing_uploads {
455+
many_builder = many_builder.push(
456+
session
457+
.put_path(prepared.path.clone())
458+
.key(&prepared.key)
459+
.expiration_policy(expiration),
460+
);
461+
}
462+
463+
let result =
464+
runtime.block_on(async { many_builder.send().await.error_for_failures().await });
465+
if let Err(errors) = result {
466+
let errors: Vec<_> = errors.collect();
467+
let error_count = errors.len();
468+
eprintln!("There were errors uploading images:");
469+
for error in errors {
470+
let error = anyhow::Error::new(error);
471+
eprintln!(" {}", style(format!("{error:#}")).red());
472+
}
473+
anyhow::bail!("Failed to upload {error_count} images");
437474
}
438-
anyhow::bail!("Failed to upload {error_count} images");
439475
}
440476

441477
println!(
442-
"{} Uploaded {} image {}",
478+
"{} Uploaded {} new image {}",
443479
style(">").dim(),
444-
style(total_count).yellow(),
445-
if total_count == 1 { "file" } else { "files" }
480+
style(upload_count).yellow(),
481+
if upload_count == 1 { "file" } else { "files" }
446482
);
447483
Ok(manifest_entries)
448484
}

0 commit comments

Comments
 (0)