max / makenotwork
1 file changed,
+88 insertions,
-23 deletions
| @@ -172,6 +172,54 @@ pub(in crate::routes::api) async fn export_content( | |||
| 172 | 172 | .context("build export accepted response") | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | + | /// Failure modes of [`spool_s3_object_to_file`]. | |
| 176 | + | enum SpoolError { | |
| 177 | + | /// The object exceeded the per-file cap mid-stream (never fully buffered). | |
| 178 | + | TooLarge, | |
| 179 | + | /// An S3 or filesystem error; the message is log-only (never user-facing). | |
| 180 | + | Io(String), | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /// Stream an S3 object to `dest`, returning the number of bytes written. Aborts | |
| 184 | + | /// with [`SpoolError::TooLarge`] the moment the running total exceeds | |
| 185 | + | /// `max_bytes`, so a mis-sized object can't fill the disk. Peak memory is one | |
| 186 | + | /// streaming chunk — this is what keeps the export's footprint at O(chunk) | |
| 187 | + | /// rather than O(file). | |
| 188 | + | async fn spool_s3_object_to_file( | |
| 189 | + | s3: &std::sync::Arc<dyn crate::storage::StorageBackend>, | |
| 190 | + | s3_key: &str, | |
| 191 | + | dest: &std::path::Path, | |
| 192 | + | max_bytes: u64, | |
| 193 | + | ) -> std::result::Result<u64, SpoolError> { | |
| 194 | + | use tokio::io::AsyncWriteExt; | |
| 195 | + | ||
| 196 | + | let mut stream = s3 | |
| 197 | + | .download_stream(s3_key) | |
| 198 | + | .await | |
| 199 | + | .map_err(|e| SpoolError::Io(e.to_string()))?; | |
| 200 | + | let mut file = tokio::fs::File::create(dest) | |
| 201 | + | .await | |
| 202 | + | .map_err(|e| SpoolError::Io(e.to_string()))?; | |
| 203 | + | let mut written: u64 = 0; | |
| 204 | + | while let Some(chunk) = stream | |
| 205 | + | .try_next() | |
| 206 | + | .await | |
| 207 | + | .map_err(|e| SpoolError::Io(e.to_string()))? | |
| 208 | + | { | |
| 209 | + | written += chunk.len() as u64; | |
| 210 | + | if written > max_bytes { | |
| 211 | + | return Err(SpoolError::TooLarge); | |
| 212 | + | } | |
| 213 | + | file.write_all(&chunk) | |
| 214 | + | .await | |
| 215 | + | .map_err(|e| SpoolError::Io(e.to_string()))?; | |
| 216 | + | } | |
| 217 | + | file.flush() | |
| 218 | + | .await | |
| 219 | + | .map_err(|e| SpoolError::Io(e.to_string()))?; | |
| 220 | + | Ok(written) | |
| 221 | + | } | |
| 222 | + | ||
| 175 | 223 | /// Build the content-export ZIP off the request path: download every file, | |
| 176 | 224 | /// zip to a tempfile, multipart-upload, and return a 1-hour presigned download | |
| 177 | 225 | /// URL. Holds the [`EXPORT_LIMITER`] permit for its lifetime so a burst can't | |
| @@ -259,34 +307,51 @@ async fn build_content_export( | |||
| 259 | 307 | return Err("Content export exceeds the 2 GB limit. Try exporting a single project instead.".to_string()); | |
| 260 | 308 | } | |
| 261 | 309 | ||
| 262 | - | match s3_clone.download_object(s3_key).await { | |
| 263 | - | Ok(data) => { | |
| 264 | - | total_size += data.len() as u64; | |
| 265 | - | if total_size > MAX_TOTAL_SIZE { | |
| 266 | - | return Err("Content export exceeds the 2 GB limit. Try exporting a single project instead.".to_string()); | |
| 267 | - | } | |
| 268 | - | let file_size = data.len() as i64; | |
| 269 | - | // Move writer + file bytes onto the blocking pool, write, get | |
| 270 | - | // the writer back. `data` drops inside the task afterward, so | |
| 271 | - | // only one file is in RAM at a time. | |
| 272 | - | let entry = zip_path_entry.clone(); | |
| 273 | - | zip = tokio::task::spawn_blocking( | |
| 274 | - | move || -> std::result::Result<_, zip::result::ZipError> { | |
| 275 | - | zip.start_file(&entry, options)?; | |
| 276 | - | zip.write_all(&data)?; | |
| 277 | - | Ok(zip) | |
| 278 | - | }, | |
| 279 | - | ) | |
| 280 | - | .await | |
| 281 | - | .map_err(|e| format!("join zip write task: {e}"))? | |
| 282 | - | .map_err(|e| format!("write file into export zip: {e}"))?; | |
| 283 | - | manifest.push((zip_path_entry.clone(), file_size)); | |
| 310 | + | // Stream the object to a per-file spool on disk instead of buffering | |
| 311 | + | // the whole (up to 500 MB) file in RAM. Peak memory is one streaming | |
| 312 | + | // chunk, not the full object — three concurrent exports previously | |
| 313 | + | // held ~1.5 GB of file bytes between them. The scan pipeline streams | |
| 314 | + | // large files the same way; the exporter now matches it (Run #5 Perf | |
| 315 | + | // S2). The MAX_FILE_SIZE guard is re-enforced mid-stream as a backstop | |
| 316 | + | // for legacy rows whose size couldn't be resolved above. | |
| 317 | + | let part_path = tmp_dir.path().join(format!("part-{}", manifest.len())); | |
| 318 | + | let part_bytes = match spool_s3_object_to_file(&s3_clone, s3_key, &part_path, MAX_FILE_SIZE).await { | |
| 319 | + | Ok(n) => n, | |
| 320 | + | Err(SpoolError::TooLarge) => { | |
| 321 | + | skipped.push(format!("{} (exceeds 500 MB per-file export cap)", zip_path_entry)); | |
| 322 | + | let _ = tokio::fs::remove_file(&part_path).await; | |
| 323 | + | continue; | |
| 284 | 324 | } | |
| 285 | - | Err(e) => { | |
| 325 | + | Err(SpoolError::Io(e)) => { | |
| 286 | 326 | tracing::warn!("Failed to download S3 key {}: {}", s3_key, e); | |
| 287 | 327 | skipped.push(zip_path_entry.clone()); | |
| 328 | + | let _ = tokio::fs::remove_file(&part_path).await; | |
| 329 | + | continue; | |
| 288 | 330 | } | |
| 331 | + | }; | |
| 332 | + | total_size += part_bytes; | |
| 333 | + | if total_size > MAX_TOTAL_SIZE { | |
| 334 | + | return Err("Content export exceeds the 2 GB limit. Try exporting a single project instead.".to_string()); | |
| 289 | 335 | } | |
| 336 | + | let file_size = part_bytes as i64; | |
| 337 | + | // Copy the spooled file into the zip on the blocking pool (the zip | |
| 338 | + | // crate's IO is synchronous). The reader streams from disk via | |
| 339 | + | // io::copy, so RAM stays at the copy buffer size, not the file size. | |
| 340 | + | let entry = zip_path_entry.clone(); | |
| 341 | + | let copy_path = part_path.clone(); | |
| 342 | + | zip = tokio::task::spawn_blocking( | |
| 343 | + | move || -> std::result::Result<_, zip::result::ZipError> { | |
| 344 | + | zip.start_file(&entry, options)?; | |
| 345 | + | let mut reader = std::fs::File::open(©_path)?; | |
| 346 | + | std::io::copy(&mut reader, &mut zip)?; | |
| 347 | + | Ok(zip) | |
| 348 | + | }, | |
| 349 | + | ) | |
| 350 | + | .await | |
| 351 | + | .map_err(|e| format!("join zip write task: {e}"))? | |
| 352 | + | .map_err(|e| format!("write file into export zip: {e}"))?; | |
| 353 | + | let _ = tokio::fs::remove_file(&part_path).await; | |
| 354 | + | manifest.push((zip_path_entry.clone(), file_size)); | |
| 290 | 355 | } | |
| 291 | 356 | ||
| 292 | 357 | if manifest.is_empty() { |