max / makenotwork
6 files changed,
+222 insertions,
-6 deletions
| @@ -541,6 +541,7 @@ pub(super) async fn cleanup_orphaned_uploads(state: &AppState) { | |||
| 541 | 541 | }; | |
| 542 | 542 | ||
| 543 | 543 | let mut cleaned = 0i64; | |
| 544 | + | let mut aborted = 0i64; | |
| 544 | 545 | // Carry (key, bucket) pairs: the deletion is bucket-scoped so a key present | |
| 545 | 546 | // in two buckets only clears the record for the bucket we reaped (B7). | |
| 546 | 547 | let mut keys_to_delete: Vec<(String, String)> = Vec::with_capacity(stale.len()); | |
| @@ -558,6 +559,18 @@ pub(super) async fn cleanup_orphaned_uploads(state: &AppState) { | |||
| 558 | 559 | crate::storage::S3Bucket::Main => state.storage.s3.as_ref(), | |
| 559 | 560 | }; | |
| 560 | 561 | if let Some(s3) = s3_client { | |
| 562 | + | // A multipart session that was started and never completed leaves NO | |
| 563 | + | // object to delete, only uploaded parts that S3 bills for until they | |
| 564 | + | // are aborted — so the delete below is a no-op against it and the | |
| 565 | + | // parts would leak forever. Abort first, keyed off S3's own list so a | |
| 566 | + | // session whose upload_id was never recorded is still caught. | |
| 567 | + | // | |
| 568 | + | // Best-effort: a failed abort must not block the object delete or | |
| 569 | + | // strand the tracking row. The bucket lifecycle rule | |
| 570 | + | // (AbortIncompleteMultipartUpload) is the backstop for anything | |
| 571 | + | // missed here. | |
| 572 | + | aborted += abort_orphan_multipart_sessions(s3.as_ref(), s3_key).await; | |
| 573 | + | ||
| 561 | 574 | // Route through the guarded funnel: if a confirm already reclaimed | |
| 562 | 575 | // this deterministic key, the live row owns the object and we must | |
| 563 | 576 | // NOT delete it — just clear the stale pending_uploads record. | |
| @@ -606,12 +619,57 @@ pub(super) async fn cleanup_orphaned_uploads(state: &AppState) { | |||
| 606 | 619 | tracing::error!(error = ?e, "failed to delete pending upload records"); | |
| 607 | 620 | } | |
| 608 | 621 | ||
| 609 | - | if cleaned > 0 { | |
| 610 | - | tracing::info!(cleaned, "cleaned up orphaned presigned uploads"); | |
| 622 | + | if cleaned > 0 || aborted > 0 { | |
| 623 | + | tracing::info!(cleaned, aborted, "cleaned up orphaned presigned uploads"); | |
| 611 | 624 | } | |
| 612 | 625 | let _ = db::scheduler_jobs::record_job_run(&state.db, "orphaned_upload_cleanup", cleaned).await; | |
| 613 | 626 | } | |
| 614 | 627 | ||
| 628 | + | /// Abort every in-progress multipart session for `s3_key`, returning how many | |
| 629 | + | /// were aborted. | |
| 630 | + | /// | |
| 631 | + | /// Best-effort by design: the caller still deletes the object and clears the | |
| 632 | + | /// tracking row regardless, and the bucket's `AbortIncompleteMultipartUpload` | |
| 633 | + | /// lifecycle rule catches whatever fails here. Returning a count rather than a | |
| 634 | + | /// Result keeps a storage hiccup from stranding the rest of the reap. | |
| 635 | + | /// Public via a single re-export on `scheduler` (the module itself stays | |
| 636 | + | /// private) so integration tests can drive it against a test backend without a | |
| 637 | + | /// database or an `AppState`. | |
| 638 | + | pub async fn abort_orphan_multipart_sessions( | |
| 639 | + | s3: &dyn crate::storage::StorageBackend, | |
| 640 | + | s3_key: &str, | |
| 641 | + | ) -> i64 { | |
| 642 | + | let upload_ids = match s3.list_multipart_uploads_for_key(s3_key).await { | |
| 643 | + | Ok(ids) => ids, | |
| 644 | + | Err(e) => { | |
| 645 | + | tracing::warn!( | |
| 646 | + | s3_key = %s3_key, error = ?e, | |
| 647 | + | "orphan reaper: could not list multipart sessions; lifecycle rule is the backstop" | |
| 648 | + | ); | |
| 649 | + | return 0; | |
| 650 | + | } | |
| 651 | + | }; | |
| 652 | + | ||
| 653 | + | let key = crate::storage::S3Key::from_stored(s3_key); | |
| 654 | + | let mut aborted = 0i64; | |
| 655 | + | for upload_id in upload_ids { | |
| 656 | + | match s3.abort_multipart_upload(&key, &upload_id).await { | |
| 657 | + | Ok(()) => { | |
| 658 | + | aborted += 1; | |
| 659 | + | tracing::info!( | |
| 660 | + | s3_key = %s3_key, %upload_id, | |
| 661 | + | "orphan reaper: aborted an abandoned multipart session" | |
| 662 | + | ); | |
| 663 | + | } | |
| 664 | + | Err(e) => tracing::warn!( | |
| 665 | + | s3_key = %s3_key, %upload_id, error = ?e, | |
| 666 | + | "orphan reaper: failed to abort multipart session; lifecycle rule is the backstop" | |
| 667 | + | ), | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | aborted | |
| 671 | + | } | |
| 672 | + | ||
| 615 | 673 | /// Remove stale cart items (>30 days old) and items that became unavailable. | |
| 616 | 674 | #[tracing::instrument(skip_all, name = "scheduler::cleanup_cart_items")] | |
| 617 | 675 | pub(super) async fn cleanup_cart_items(state: &AppState) { |
| @@ -7,6 +7,10 @@ | |||
| 7 | 7 | ||
| 8 | 8 | mod announcements; | |
| 9 | 9 | mod cleanup; | |
| 10 | + | /// Re-exported for integration tests: abandoned multipart sessions hold billed | |
| 11 | + | /// parts, so the reap path is worth exercising directly. The rest of `cleanup` | |
| 12 | + | /// stays private. | |
| 13 | + | pub use cleanup::abort_orphan_multipart_sessions; | |
| 10 | 14 | #[doc(hidden)] | |
| 11 | 15 | pub use cleanup::drain_pending_s3_deletions_for_test; | |
| 12 | 16 | mod integrity; |
| @@ -543,6 +543,11 @@ pub trait StorageBackend: Send + Sync { | |||
| 543 | 543 | /// pending-upload reaper calls this on sessions that were never confirmed — | |
| 544 | 544 | /// incomplete multipart uploads bill for their parts indefinitely. | |
| 545 | 545 | async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()>; | |
| 546 | + | /// Upload ids of the in-progress multipart sessions for exactly `s3_key`. | |
| 547 | + | /// The reaper recovers them from S3 rather than the database, so a session | |
| 548 | + | /// whose tracking row was lost is still cleaned up. Required (not defaulted): | |
| 549 | + | /// an empty default would silently strand billed parts. | |
| 550 | + | async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>>; | |
| 546 | 551 | async fn check_connectivity(&self) -> std::result::Result<(), String>; | |
| 547 | 552 | fn bucket(&self) -> &str; | |
| 548 | 553 | } | |
| @@ -1002,6 +1007,15 @@ impl S3Client { | |||
| 1002 | 1007 | .map_err(AppError::Storage) | |
| 1003 | 1008 | } | |
| 1004 | 1009 | ||
| 1010 | + | /// In-progress multipart sessions for a key. See the | |
| 1011 | + | /// [`StorageBackend::list_multipart_uploads_for_key`] trait method. | |
| 1012 | + | pub async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>> { | |
| 1013 | + | self.inner | |
| 1014 | + | .list_multipart_uploads_for_key(s3_key) | |
| 1015 | + | .await | |
| 1016 | + | .map_err(AppError::Storage) | |
| 1017 | + | } | |
| 1018 | + | ||
| 1005 | 1019 | /// Lightweight connectivity check — issues a list with max_keys(0). | |
| 1006 | 1020 | pub async fn check_connectivity(&self) -> std::result::Result<(), String> { | |
| 1007 | 1021 | self.inner.check_connectivity().await | |
| @@ -1267,6 +1281,10 @@ impl StorageBackend for S3Client { | |||
| 1267 | 1281 | self.abort_multipart_upload(s3_key, upload_id).await | |
| 1268 | 1282 | } | |
| 1269 | 1283 | ||
| 1284 | + | async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>> { | |
| 1285 | + | self.list_multipart_uploads_for_key(s3_key).await | |
| 1286 | + | } | |
| 1287 | + | ||
| 1270 | 1288 | async fn check_connectivity(&self) -> std::result::Result<(), String> { | |
| 1271 | 1289 | self.check_connectivity().await | |
| 1272 | 1290 | } |
| @@ -9,6 +9,10 @@ use std::sync::Mutex; | |||
| 9 | 9 | /// Files are stored in a `HashMap<String, Vec<u8>>` behind a `Mutex`. | |
| 10 | 10 | pub struct InMemoryStorage { | |
| 11 | 11 | objects: Mutex<HashMap<String, Vec<u8>>>, | |
| 12 | + | /// Open multipart sessions: `upload_id -> s3_key`. Tracked for real (not | |
| 13 | + | /// stubbed) so the orphan reaper's abort path is observable in tests — an | |
| 14 | + | /// unaborted session is exactly the leak this models. | |
| 15 | + | multipart: Mutex<HashMap<String, String>>, | |
| 12 | 16 | bucket: String, | |
| 13 | 17 | } | |
| 14 | 18 | ||
| @@ -17,10 +21,25 @@ impl InMemoryStorage { | |||
| 17 | 21 | pub fn new() -> Self { | |
| 18 | 22 | InMemoryStorage { | |
| 19 | 23 | objects: Mutex::new(HashMap::new()), | |
| 24 | + | multipart: Mutex::new(HashMap::new()), | |
| 20 | 25 | bucket: "test-bucket".to_string(), | |
| 21 | 26 | } | |
| 22 | 27 | } | |
| 23 | 28 | ||
| 29 | + | /// Number of multipart sessions still open. Zero after a clean reap. | |
| 30 | + | pub fn open_multipart_count(&self) -> usize { | |
| 31 | + | self.multipart.lock().unwrap().len() | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Open a multipart session directly, standing in for one a client started | |
| 35 | + | /// and abandoned. | |
| 36 | + | pub fn put_open_multipart(&self, upload_id: &str, s3_key: &str) { | |
| 37 | + | self.multipart | |
| 38 | + | .lock() | |
| 39 | + | .unwrap() | |
| 40 | + | .insert(upload_id.to_string(), s3_key.to_string()); | |
| 41 | + | } | |
| 42 | + | ||
| 24 | 43 | /// Pre-populate a file so that subsequent `object_exists` / `download_object` | |
| 25 | 44 | /// calls see it. Useful for testing confirm_upload flows. | |
| 26 | 45 | pub fn put(&self, key: &str, data: Vec<u8>) { | |
| @@ -129,7 +148,9 @@ impl StorageBackend for InMemoryStorage { | |||
| 129 | 148 | // confirm path, exactly as they do for `presign_upload` + confirm. | |
| 130 | 149 | ||
| 131 | 150 | async fn create_multipart_upload(&self, s3_key: &S3Key, _content_type: &str) -> Result<String> { | |
| 132 | - | Ok(format!("test-upload-id/{}", s3_key)) | |
| 151 | + | let upload_id = format!("test-upload-id/{}", s3_key); | |
| 152 | + | self.put_open_multipart(&upload_id, s3_key.as_str()); | |
| 153 | + | Ok(upload_id) | |
| 133 | 154 | } | |
| 134 | 155 | ||
| 135 | 156 | async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, _expiry_secs: Option<u64>, _max_bytes: Option<i64>) -> Result<String> { | |
| @@ -144,20 +165,34 @@ impl StorageBackend for InMemoryStorage { | |||
| 144 | 165 | Ok(format!("http://test-storage/{s3_key}?uploadId={upload_id}&partNumber={part_number}")) | |
| 145 | 166 | } | |
| 146 | 167 | ||
| 147 | - | async fn complete_multipart_upload(&self, _s3_key: &S3Key, _upload_id: &str, parts: &[(i32, String)]) -> Result<()> { | |
| 168 | + | async fn complete_multipart_upload(&self, _s3_key: &S3Key, upload_id: &str, parts: &[(i32, String)]) -> Result<()> { | |
| 148 | 169 | if parts.is_empty() { | |
| 149 | 170 | return Err(AppError::Storage( | |
| 150 | 171 | "cannot complete a multipart upload with no parts".to_string(), | |
| 151 | 172 | )); | |
| 152 | 173 | } | |
| 174 | + | // Completing closes the session, so it no longer holds billed parts. | |
| 175 | + | self.multipart.lock().unwrap().remove(upload_id); | |
| 153 | 176 | Ok(()) | |
| 154 | 177 | } | |
| 155 | 178 | ||
| 156 | - | async fn abort_multipart_upload(&self, _s3_key: &S3Key, _upload_id: &str) -> Result<()> { | |
| 157 | - | // Idempotent: nothing is staged, so aborting an unknown session is fine. | |
| 179 | + | async fn abort_multipart_upload(&self, _s3_key: &S3Key, upload_id: &str) -> Result<()> { | |
| 180 | + | // Idempotent: aborting an unknown session is fine. | |
| 181 | + | self.multipart.lock().unwrap().remove(upload_id); | |
| 158 | 182 | Ok(()) | |
| 159 | 183 | } | |
| 160 | 184 | ||
| 185 | + | async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>> { | |
| 186 | + | Ok(self | |
| 187 | + | .multipart | |
| 188 | + | .lock() | |
| 189 | + | .unwrap() | |
| 190 | + | .iter() | |
| 191 | + | .filter(|(_, key)| key.as_str() == s3_key) | |
| 192 | + | .map(|(id, _)| id.clone()) | |
| 193 | + | .collect()) | |
| 194 | + | } | |
| 195 | + | ||
| 161 | 196 | async fn check_connectivity(&self) -> std::result::Result<(), String> { | |
| 162 | 197 | Ok(()) | |
| 163 | 198 | } |
| @@ -1835,3 +1835,45 @@ async fn multipart_complete_then_confirm_commits_the_upload() { | |||
| 1835 | 1835 | .unwrap(); | |
| 1836 | 1836 | assert_eq!(storage_used, size, "confirm must charge the real object size"); | |
| 1837 | 1837 | } | |
| 1838 | + | ||
| 1839 | + | #[tokio::test] | |
| 1840 | + | async fn orphan_reaper_aborts_abandoned_multipart_sessions() { | |
| 1841 | + | // A multipart session that was started and never completed leaves NO object | |
| 1842 | + | // to delete, only uploaded parts that S3 bills for until they are aborted. | |
| 1843 | + | // The reaper's object delete is a no-op against it, so without this abort | |
| 1844 | + | // the parts leak indefinitely — a cost bug, not just a tidiness one. | |
| 1845 | + | use makenotwork::scheduler::abort_orphan_multipart_sessions; | |
| 1846 | + | ||
| 1847 | + | let mem = crate::harness::storage::InMemoryStorage::new(); | |
| 1848 | + | let target = "staging/abandoned-uuid/movie.mp4"; | |
| 1849 | + | ||
| 1850 | + | // Two abandoned sessions on the reaped key, plus one on a different key | |
| 1851 | + | // that must survive: ListMultipartUploads matches a PREFIX, so a careless | |
| 1852 | + | // implementation reaping `staging/abc` would also kill `staging/abcdef`. | |
| 1853 | + | mem.put_open_multipart("upload-a", target); | |
| 1854 | + | mem.put_open_multipart("upload-b", target); | |
| 1855 | + | mem.put_open_multipart("upload-c", "staging/abandoned-uuid/movie.mp4.other"); | |
| 1856 | + | assert_eq!(mem.open_multipart_count(), 3); | |
| 1857 | + | ||
| 1858 | + | let aborted = abort_orphan_multipart_sessions(&mem, target).await; | |
| 1859 | + | ||
| 1860 | + | assert_eq!(aborted, 2, "both sessions on the reaped key must be aborted"); | |
| 1861 | + | assert_eq!( | |
| 1862 | + | mem.open_multipart_count(), | |
| 1863 | + | 1, | |
| 1864 | + | "a session on a different key must survive the prefix-adjacent reap" | |
| 1865 | + | ); | |
| 1866 | + | } | |
| 1867 | + | ||
| 1868 | + | #[tokio::test] | |
| 1869 | + | async fn orphan_reaper_abort_is_a_noop_when_there_is_no_session() { | |
| 1870 | + | // The common case: an ordinary single-PUT orphan has no multipart session, | |
| 1871 | + | // and the reaper must sail past it without erroring or miscounting. | |
| 1872 | + | use makenotwork::scheduler::abort_orphan_multipart_sessions; | |
| 1873 | + | ||
| 1874 | + | let mem = crate::harness::storage::InMemoryStorage::new(); | |
| 1875 | + | mem.put("staging/plain/song.mp3", b"bytes".to_vec()); | |
| 1876 | + | ||
| 1877 | + | let aborted = abort_orphan_multipart_sessions(&mem, "staging/plain/song.mp3").await; | |
| 1878 | + | assert_eq!(aborted, 0); | |
| 1879 | + | } |
| @@ -905,6 +905,65 @@ impl S3Client { | |||
| 905 | 905 | .map_err(|e| format!("S3 abort multipart upload for {key} failed: {e}")) | |
| 906 | 906 | } | |
| 907 | 907 | ||
| 908 | + | /// List the in-progress multipart uploads for exactly `key`, returning their | |
| 909 | + | /// upload ids. | |
| 910 | + | /// | |
| 911 | + | /// The orphan reaper needs this: a session that was started but never | |
| 912 | + | /// completed has **no object to delete**, only uploaded parts that S3 bills | |
| 913 | + | /// for until they are aborted, so a plain `delete` is a no-op against it. The | |
| 914 | + | /// upload id is not recorded durably anywhere, so it is recovered from S3, | |
| 915 | + | /// which also catches sessions whose tracking row was lost entirely. | |
| 916 | + | /// | |
| 917 | + | /// `ListMultipartUploads` matches a *prefix*, so results are filtered to an | |
| 918 | + | /// exact key match — otherwise reaping `staging/abc` would also abort a live | |
| 919 | + | /// session for `staging/abcdef`. | |
| 920 | + | pub async fn list_multipart_uploads_for_key(&self, key: &str) -> Result<Vec<String>, String> { | |
| 921 | + | let mut ids = Vec::new(); | |
| 922 | + | let mut key_marker: Option<String> = None; | |
| 923 | + | let mut upload_id_marker: Option<String> = None; | |
| 924 | + | ||
| 925 | + | loop { | |
| 926 | + | let mut req = self | |
| 927 | + | .client | |
| 928 | + | .list_multipart_uploads() | |
| 929 | + | .bucket(&self.bucket) | |
| 930 | + | .prefix(key); | |
| 931 | + | if let Some(ref k) = key_marker { | |
| 932 | + | req = req.key_marker(k); | |
| 933 | + | } | |
| 934 | + | if let Some(ref u) = upload_id_marker { | |
| 935 | + | req = req.upload_id_marker(u); | |
| 936 | + | } | |
| 937 | + | ||
| 938 | + | let resp = req | |
| 939 | + | .send() | |
| 940 | + | .await | |
| 941 | + | .map_err(|e| format!("S3 list_multipart_uploads for {key} failed: {e}"))?; | |
| 942 | + | ||
| 943 | + | for upload in resp.uploads() { | |
| 944 | + | // Exact-key filter: the request matched on prefix. | |
| 945 | + | if upload.key() == Some(key) | |
| 946 | + | && let Some(id) = upload.upload_id() | |
| 947 | + | { | |
| 948 | + | ids.push(id.to_string()); | |
| 949 | + | } | |
| 950 | + | } | |
| 951 | + | ||
| 952 | + | if resp.is_truncated().unwrap_or(false) { | |
| 953 | + | key_marker = resp.next_key_marker().map(str::to_string); | |
| 954 | + | upload_id_marker = resp.next_upload_id_marker().map(str::to_string); | |
| 955 | + | // Defensive: a truncated response with no markers would loop forever. | |
| 956 | + | if key_marker.is_none() && upload_id_marker.is_none() { | |
| 957 | + | break; | |
| 958 | + | } | |
| 959 | + | } else { | |
| 960 | + | break; | |
| 961 | + | } | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | Ok(ids) | |
| 965 | + | } | |
| 966 | + | ||
| 908 | 967 | /// Server-side copy an object using multipart `UploadPartCopy`, for sources | |
| 909 | 968 | /// larger than the 5 GiB single-part [`Self::copy_object`]/`CopyObject` | |
| 910 | 969 | /// limit. No bytes transit the caller — S3 copies each byte range internally. |