Skip to content

Commit 0da6835

Browse files
authored
🐛 fix(pypi): propagate prefetch errors (#1858)
Resident lookups converted storage failures into zero-byte cache hits, while artifact downloads reopened their materialized path and could panic. Reuse HEAD metadata and verified transfer lengths so row failures and byte totals match storage outcomes.
1 parent 8342e11 commit 0da6835

6 files changed

Lines changed: 123 additions & 62 deletions

File tree

crates/peryx-ecosystem-pypi/src/cache/download.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,29 @@ pub async fn file_path(
3232
route: String,
3333
filename: String,
3434
) -> Result<BlobLease, CacheError> {
35+
Ok(file_path_with_size(state, digest, route, filename).await?.0)
36+
}
37+
38+
pub async fn file_path_with_size(
39+
state: Arc<ServingState>,
40+
digest: Digest,
41+
route: String,
42+
filename: String,
43+
) -> Result<(BlobLease, u64), CacheError> {
3544
ensure_digest_clear(&state, &digest)?;
36-
if state.blobs.head(&digest).await?.is_some() {
37-
return Ok(state.blobs.materialize(&digest).await?);
45+
if let Some(metadata) = state.blobs.head(&digest).await? {
46+
return Ok((state.blobs.materialize(&digest).await?, metadata.bytes));
3847
}
3948
let mut handle = {
4049
let gate = flight_gate(&state, digest.as_str());
4150
let guard = gate.lock_owned().await;
42-
if state.blobs.head(&digest).await?.is_some() {
51+
if let Some(metadata) = state.blobs.head(&digest).await? {
4352
release_flight(&state, digest.as_str(), guard);
44-
return Ok(state.blobs.materialize(&digest).await?);
53+
return Ok((state.blobs.materialize(&digest).await?, metadata.bytes));
4554
}
46-
if fill_remote(&state, &digest).await.is_some() {
55+
if let Some(metadata) = fill_remote(&state, &digest).await {
4756
release_flight(&state, digest.as_str(), guard);
48-
return Ok(state.blobs.materialize(&digest).await?);
57+
return Ok((state.blobs.materialize(&digest).await?, metadata.bytes));
4958
}
5059
let handle = if let Some(running) = existing_download(&state, &digest) {
5160
running
@@ -55,8 +64,8 @@ pub async fn file_path(
5564
release_flight(&state, digest.as_str(), guard);
5665
handle
5766
};
58-
wait_for_download(&mut handle).await?;
59-
Ok(state.blobs.materialize(&digest).await?)
67+
let bytes = wait_for_download(&mut handle).await?;
68+
Ok((state.blobs.materialize(&digest).await?, bytes))
6069
}
6170

6271
pub enum FileProbe {
@@ -202,11 +211,12 @@ fn existing_download(state: &ServingState, digest: &Digest) -> Option<DownloadHa
202211
state.downloads.get(digest.as_str())
203212
}
204213

205-
async fn wait_for_download(handle: &mut DownloadHandle) -> Result<(), CacheError> {
214+
async fn wait_for_download(handle: &mut DownloadHandle) -> Result<u64, CacheError> {
206215
loop {
207-
let done = handle.progress().borrow_and_update().done.clone();
216+
let progress = handle.progress().borrow_and_update().clone();
217+
let done = progress.done;
208218
match done {
209-
Some(Ok(())) => return Ok(()),
219+
Some(Ok(())) => return Ok(progress.flushed),
210220
Some(Err(message)) => return Err(CacheError::Stream(message)),
211221
None => {
212222
handle

crates/peryx-ecosystem-pypi/src/cache/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mod resolve;
2222
mod shadow;
2323

2424
pub(crate) use download::download_dimensions;
25+
pub(crate) use download::file_path_with_size;
2526
pub use download::{FileOutcome, FileProbe, file_path, probe_file, stream_file};
2627
pub use fetch::{
2728
MAX_PROJECT_BYTES, MAX_PROJECT_FILES, ProjectSyncError, ProjectSyncOutcome, RefreshSummary, refresh_stale_pages,

crates/peryx-ecosystem-pypi/src/mirror/report.rs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
use std::io::Write;
22
use std::time::{SystemTime, UNIX_EPOCH};
33

4-
use peryx_driver::ServingState;
5-
use peryx_storage::blob::Digest;
6-
74
use super::{PrefetchFile, Row};
85

96
pub(super) fn write_page_row(
@@ -97,16 +94,6 @@ pub(super) fn write_count(
9794
)
9895
}
9996

100-
pub(super) async fn blob_size(state: &ServingState, digest: &Digest) -> u64 {
101-
state
102-
.blobs
103-
.head(digest)
104-
.await
105-
.ok()
106-
.flatten()
107-
.map_or(0, |metadata| metadata.bytes)
108-
}
109-
11097
pub(super) fn unix_now() -> u64 {
11198
SystemTime::now()
11299
.duration_since(UNIX_EPOCH)

crates/peryx-ecosystem-pypi/src/mirror/run.rs

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ use anyhow::{Context as _, bail};
88
use peryx_driver::{AppState, ServingState};
99
use peryx_storage::blob::Digest;
1010

11-
use super::report::{
12-
blob_size, unix_now, write_count, write_file_row, write_file_row_bytes, write_page_row, write_row,
13-
};
11+
use super::report::{unix_now, write_count, write_file_row, write_file_row_bytes, write_page_row, write_row};
1412
use super::selection::{candidates, content_type_is_json, selection, target};
1513
use super::{
1614
BlobCheck, FileCandidate, HEADER, PrefetchConfig, PrefetchFile, PrefetchOptions, Row, Selection, SelectionSource,
@@ -280,16 +278,12 @@ async fn sync_file(
280278
file: &PrefetchFile,
281279
) -> Result<SyncOutcome, crate::cache::CacheError> {
282280
let digest = Digest::from_hex(&file.digest).ok_or(crate::cache::CacheError::FileNotFound)?;
283-
if state.blobs.head(&digest).await?.is_some() {
284-
return Ok(SyncOutcome::Cached(blob_size(&state, &digest).await));
281+
if let Some(metadata) = state.blobs.head(&digest).await? {
282+
return Ok(SyncOutcome::Cached(metadata.bytes));
285283
}
286-
let path = crate::cache::file_path(state, digest.clone(), target.route.clone(), file.filename.clone()).await?;
287-
Ok(SyncOutcome::Downloaded(
288-
path.path()
289-
.metadata()
290-
.expect("a blob lease keeps its materialized path available")
291-
.len(),
292-
))
284+
let (_, bytes) =
285+
crate::cache::file_path_with_size(state, digest, target.route.clone(), file.filename.clone()).await?;
286+
Ok(SyncOutcome::Downloaded(bytes))
293287
}
294288

295289
async fn sync_metadata(
@@ -301,8 +295,8 @@ async fn sync_metadata(
301295
) -> Result<SyncOutcome, crate::cache::CacheError> {
302296
let artifact = Digest::from_hex(artifact_digest).ok_or(crate::cache::CacheError::FileNotFound)?;
303297
let metadata = Digest::from_hex(metadata_digest).ok_or(crate::cache::CacheError::FileNotFound)?;
304-
if state.blobs.head(&metadata).await?.is_some() {
305-
return Ok(SyncOutcome::Cached(blob_size(state, &metadata).await));
298+
if let Some(metadata) = state.blobs.head(&metadata).await? {
299+
return Ok(SyncOutcome::Cached(metadata.bytes));
306300
}
307301
Ok(SyncOutcome::Downloaded(
308302
crate::cache::metadata_bytes(state, &artifact, route, metadata_filename)

crates/peryx-ecosystem-pypi/tests/unit/mirror/report_tests.rs

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
use peryx_storage::blob::Digest;
2-
3-
use super::{blob_size, unix_now, write_count, write_file_row, write_file_row_bytes, write_page_row, write_row};
4-
use crate::mirror::test_support;
1+
use super::{unix_now, write_count, write_file_row, write_file_row_bytes, write_page_row, write_row};
52
use crate::mirror::{PrefetchFile, PrefetchMetadata, Row};
63

74
fn file() -> PrefetchFile {
@@ -48,23 +45,6 @@ fn report_writes_each_row_shape() {
4845
assert!(rows.contains("metadata\tpypi\tdemo\tdemo.metadata"));
4946
}
5047

51-
#[tokio::test]
52-
async fn blob_size_reports_present_and_missing_blobs() {
53-
let fixture = test_support::state(Vec::new());
54-
let present = Digest::of(b"present");
55-
fixture
56-
.state
57-
.serving
58-
.blobs
59-
.blocking()
60-
.put_bytes_as(b"present", &present)
61-
.unwrap();
62-
63-
assert_eq!(blob_size(&fixture.state.serving, &present).await, 7);
64-
assert_eq!(blob_size(&fixture.state.serving, &Digest::of(b"missing")).await, 0);
65-
assert!(fixture.dir.path().exists());
66-
}
67-
6848
#[test]
6949
fn unix_now_is_after_the_epoch() {
7050
assert!(unix_now() > 0);

crates/peryx-ecosystem-pypi/tests/unit/mirror/run_tests.rs

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,80 @@ async fn sync_downloads_metadata_and_artifacts() {
284284
.unwrap();
285285

286286
let output = String::from_utf8(output).unwrap();
287-
assert!(output.contains("downloaded"));
288-
assert!(output.contains("skipped"));
287+
assert_eq!(
288+
reported_sizes(&output, "downloaded"),
289+
[
290+
("metadata", "demo-1.0-py3-none-any.whl.metadata", "8"),
291+
("file", "demo-1.0-py3-none-any.whl", "8"),
292+
("file", "demo-1.0.zip", "5"),
293+
]
294+
);
289295
assert!(output.contains("files_downloaded\t\t\t3"));
296+
assert!(output.contains("bytes_downloaded\t\t\t21"));
297+
server.verify().await;
298+
}
299+
300+
#[tokio::test]
301+
async fn mirror_sync_reports_blob_head_errors_for_artifacts_and_metadata() {
302+
let server = MockServer::start().await;
303+
let mut detail = artifact_detail(&server.uri());
304+
detail.files.truncate(1);
305+
Mock::given(method("GET"))
306+
.and(path("/simple/demo/"))
307+
.respond_with(ResponseTemplate::new(200).set_body_raw(to_json(&detail), "application/vnd.pypi.simple.v1+json"))
308+
.expect(1)
309+
.mount(&server)
310+
.await;
311+
let fixture = test_support::state(vec![cached_index(&format!("{}/simple/", server.uri()), false)]);
312+
let store = fixture.state.serving.blobs.filesystem_store().unwrap();
313+
for digest in [Digest::of(b"artifact"), Digest::of(b"metadata")] {
314+
let path = store.path_for(&digest);
315+
std::fs::create_dir_all(path.parent().unwrap().parent().unwrap()).unwrap();
316+
std::fs::write(path.parent().unwrap(), b"not a directory").unwrap();
317+
}
318+
let configured = toml::Table::from_iter([
319+
("mode".to_owned(), toml::Value::String("selected".to_owned())),
320+
(
321+
"packages".to_owned(),
322+
toml::Value::Array(vec![toml::Value::String("demo".to_owned())]),
323+
),
324+
]);
325+
let mut output = Vec::new();
326+
327+
let error = crate::PypiServing
328+
.mirror(
329+
fixture.state,
330+
MirrorRequest {
331+
action: MirrorAction::Sync,
332+
index: "pypi",
333+
settings: &toml::Table::new(),
334+
configured: &configured,
335+
overrides: &toml::Table::new(),
336+
},
337+
&mut output,
338+
)
339+
.await
340+
.unwrap_err();
341+
342+
assert_eq!(error, "prefetch sync found 2 failure(s)");
343+
let output = String::from_utf8(output).unwrap();
344+
assert_eq!(
345+
output
346+
.lines()
347+
.filter_map(|line| {
348+
let cells = line.split('\t').collect::<Vec<_>>();
349+
(cells.get(7) == Some(&"failure"))
350+
.then(|| (cells[0], cells[3], cells[6], cells[8].starts_with("blob store error:")))
351+
})
352+
.collect::<Vec<_>>(),
353+
[
354+
("metadata", "demo-1.0-py3-none-any.whl.metadata", "", true),
355+
("file", "demo-1.0-py3-none-any.whl", "8", true),
356+
]
357+
);
358+
assert!(output.contains("files_downloaded\t\t\t0"));
359+
assert!(output.contains("bytes_downloaded\t\t\t0"));
360+
assert!(output.contains("failures\t\t\t2"));
290361
server.verify().await;
291362
}
292363

@@ -494,7 +565,15 @@ async fn sync_files_reports_cached_metadata_only_and_filtered_files() {
494565
)
495566
.await
496567
.unwrap();
497-
assert!(String::from_utf8(output).unwrap().contains("cached"));
568+
let output = String::from_utf8(output).unwrap();
569+
assert_eq!(
570+
reported_sizes(&output, "cached"),
571+
[
572+
("metadata", "demo-1.0-py3-none-any.whl.metadata", "8"),
573+
("file", "demo-1.0-py3-none-any.whl", "8"),
574+
("file", "demo-1.0.zip", "5"),
575+
]
576+
);
498577
assert_eq!(summary.skipped, 1);
499578

500579
selection.filters.metadata_only = true;
@@ -793,3 +872,13 @@ async fn mirror_driver_reports_a_valid_empty_selection_override() {
793872
);
794873
assert!(output.is_empty());
795874
}
875+
876+
fn reported_sizes<'output>(output: &'output str, status: &str) -> Vec<(&'output str, &'output str, &'output str)> {
877+
output
878+
.lines()
879+
.filter_map(|line| {
880+
let cells = line.split('\t').collect::<Vec<_>>();
881+
(cells.get(7) == Some(&status)).then(|| (cells[0], cells[3], cells[6]))
882+
})
883+
.collect()
884+
}

0 commit comments

Comments
 (0)