Skip to content

Commit a9a83f1

Browse files
authored
feat: add option to disable bulk delete for aws (#734)
* feat: add option to disable bulk delete for aws * fix(aws): correct documentation references for delete methods * test: add more unit tests
1 parent 255b5f5 commit a9a83f1

3 files changed

Lines changed: 227 additions & 0 deletions

File tree

src/aws/builder.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ pub struct AmazonS3Builder {
181181
conditional_put: ConfigValue<S3ConditionalPut>,
182182
/// Ignore tags
183183
disable_tagging: ConfigValue<bool>,
184+
/// Disable bulk delete
185+
disable_bulk_delete: ConfigValue<bool>,
184186
/// Encryption (See [`S3EncryptionConfigKey`])
185187
encryption_type: Option<ConfigValue<S3EncryptionType>>,
186188
encryption_kms_key_id: Option<String>,
@@ -429,6 +431,19 @@ pub enum AmazonS3ConfigKey {
429431
/// - `disable_tagging`
430432
DisableTagging,
431433

434+
/// Disable bulk delete (`DeleteObjects`, `POST /?delete`)
435+
///
436+
/// If set to `true`, [`delete`](crate::ObjectStoreExt::delete) and
437+
/// [`delete_stream`](crate::ObjectStore::delete_stream) will issue
438+
/// single-object `DELETE /key` requests instead of the bulk `DeleteObjects`
439+
/// API (`POST /?delete`). Use this for S3-compatible providers that do not
440+
/// implement `DeleteObjects` (e.g. Alibaba Cloud OSS).
441+
///
442+
/// Supported keys:
443+
/// - `aws_disable_bulk_delete`
444+
/// - `disable_bulk_delete`
445+
DisableBulkDelete,
446+
432447
/// Enable Support for S3 Express One Zone
433448
///
434449
/// Supported keys:
@@ -478,6 +493,7 @@ impl AsRef<str> for AmazonS3ConfigKey {
478493
Self::CopyIfNotExists => "aws_copy_if_not_exists",
479494
Self::ConditionalPut => "aws_conditional_put",
480495
Self::DisableTagging => "aws_disable_tagging",
496+
Self::DisableBulkDelete => "aws_disable_bulk_delete",
481497
Self::RequestPayer => "aws_request_payer",
482498
Self::Client(opt) => opt.as_ref(),
483499
Self::Encryption(opt) => opt.as_ref(),
@@ -525,6 +541,7 @@ impl FromStr for AmazonS3ConfigKey {
525541
"aws_copy_if_not_exists" | "copy_if_not_exists" => Ok(Self::CopyIfNotExists),
526542
"aws_conditional_put" | "conditional_put" => Ok(Self::ConditionalPut),
527543
"aws_disable_tagging" | "disable_tagging" => Ok(Self::DisableTagging),
544+
"aws_disable_bulk_delete" | "disable_bulk_delete" => Ok(Self::DisableBulkDelete),
528545
"aws_request_payer" | "request_payer" => Ok(Self::RequestPayer),
529546
// Backwards compatibility
530547
"aws_allow_http" => Ok(Self::Client(ClientConfigKey::AllowHttp)),
@@ -672,6 +689,7 @@ impl AmazonS3Builder {
672689
}
673690
AmazonS3ConfigKey::SkipSignature => self.skip_signature.parse(value),
674691
AmazonS3ConfigKey::DisableTagging => self.disable_tagging.parse(value),
692+
AmazonS3ConfigKey::DisableBulkDelete => self.disable_bulk_delete.parse(value),
675693
AmazonS3ConfigKey::CopyIfNotExists => {
676694
self.copy_if_not_exists = Some(ConfigValue::Deferred(value.into()))
677695
}
@@ -745,6 +763,7 @@ impl AmazonS3Builder {
745763
}
746764
AmazonS3ConfigKey::ConditionalPut => Some(self.conditional_put.to_string()),
747765
AmazonS3ConfigKey::DisableTagging => Some(self.disable_tagging.to_string()),
766+
AmazonS3ConfigKey::DisableBulkDelete => Some(self.disable_bulk_delete.to_string()),
748767
AmazonS3ConfigKey::RequestPayer => Some(self.request_payer.to_string()),
749768
AmazonS3ConfigKey::Encryption(key) => match key {
750769
S3EncryptionConfigKey::ServerSideEncryption => {
@@ -1018,6 +1037,21 @@ impl AmazonS3Builder {
10181037
self
10191038
}
10201039

1040+
/// If set to `true`, [`delete`](crate::ObjectStoreExt::delete) and
1041+
/// [`delete_stream`](crate::ObjectStore::delete_stream) will issue
1042+
/// single-object `DELETE /key` requests instead of the bulk `DeleteObjects`
1043+
/// API (`POST /?delete`).
1044+
///
1045+
/// The bulk `DeleteObjects` API is more efficient but is not implemented by
1046+
/// all S3-compatible providers (e.g. Alibaba Cloud OSS). Setting this to
1047+
/// `true` restores the single-object delete behaviour that works against
1048+
/// every S3-compatible provider, at the cost of throughput when deleting
1049+
/// many objects via [`delete_stream`](crate::ObjectStore::delete_stream).
1050+
pub fn with_disable_bulk_delete(mut self, disable: bool) -> Self {
1051+
self.disable_bulk_delete = disable.into();
1052+
self
1053+
}
1054+
10211055
/// Use SSE-KMS for server side encryption.
10221056
pub fn with_sse_kms_encryption(mut self, kms_key_id: impl Into<String>) -> Self {
10231057
self.encryption_type = Some(ConfigValue::Parsed(S3EncryptionType::SseKms));
@@ -1241,6 +1275,7 @@ impl AmazonS3Builder {
12411275
sign_payload: !self.unsigned_payload.get()?,
12421276
skip_signature: self.skip_signature.get()?,
12431277
disable_tagging: self.disable_tagging.get()?,
1278+
disable_bulk_delete: self.disable_bulk_delete.get()?,
12441279
checksum,
12451280
copy_if_not_exists,
12461281
conditional_put: self.conditional_put.get()?,

src/aws/client.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ pub(crate) struct S3Config {
205205
pub sign_payload: bool,
206206
pub skip_signature: bool,
207207
pub disable_tagging: bool,
208+
pub disable_bulk_delete: bool,
208209
pub checksum: Option<Checksum>,
209210
pub copy_if_not_exists: Option<S3CopyIfNotExists>,
210211
pub conditional_put: S3ConditionalPut,
@@ -615,6 +616,16 @@ impl S3Client {
615616
Ok(results)
616617
}
617618

619+
/// Make a single-object S3 Delete request <https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html>
620+
///
621+
/// Unlike [`bulk_delete_request`](Self::bulk_delete_request), this issues a
622+
/// plain `DELETE /key` request, which is part of the core S3 API and is
623+
/// supported by every S3-compatible provider.
624+
pub(crate) async fn delete_request(&self, path: &Path) -> Result<()> {
625+
self.request(Method::DELETE, path).send().await?;
626+
Ok(())
627+
}
628+
618629
/// Make an S3 Copy request <https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html>
619630
pub(crate) fn copy_request<'a>(&'a self, from: &Path, to: &'a Path) -> Request<'a> {
620631
let source = format!("{}/{}", self.config.bucket, encode_path(from));
@@ -1008,10 +1019,13 @@ fn encode_path(path: &Path) -> PercentEncode<'_> {
10081019
mod tests {
10091020
use super::*;
10101021
use crate::GetOptions;
1022+
use crate::ObjectStore;
1023+
use crate::aws::{AmazonS3, AmazonS3Builder};
10111024
use crate::client::HttpClient;
10121025
use crate::client::get::GetClient;
10131026
use crate::client::mock_server::MockServer;
10141027
use crate::client::retry::RetryContext;
1028+
use futures_util::{StreamExt, TryStreamExt};
10151029
use http::Response;
10161030
use http::header::{AUTHORIZATION, CONTENT_LENGTH};
10171031
use hyper::Request;
@@ -1049,6 +1063,7 @@ mod tests {
10491063
retry_config: Default::default(),
10501064
sign_payload: false,
10511065
disable_tagging: false,
1066+
disable_bulk_delete: false,
10521067
checksum: None,
10531068
copy_if_not_exists: None,
10541069
conditional_put: Default::default(),
@@ -1104,6 +1119,7 @@ mod tests {
11041119
retry_config: Default::default(),
11051120
sign_payload: false,
11061121
disable_tagging: false,
1122+
disable_bulk_delete: false,
11071123
checksum: None,
11081124
copy_if_not_exists: None,
11091125
conditional_put: Default::default(),
@@ -1155,6 +1171,163 @@ mod tests {
11551171
mock.shutdown().await;
11561172
}
11571173

1174+
/// `(method, path, query)` captured for assertion outside the mock closure.
1175+
///
1176+
/// `MockServer` swallows panics raised inside its response handler (the
1177+
/// connection just resets and the S3 retry logic can still surface an `Ok`
1178+
/// result), so assertions placed inside the closure are silently ignored.
1179+
/// We capture into shared state and assert in the test body instead.
1180+
type CapturedRequest = (Method, String, Option<String>);
1181+
1182+
fn capture(captured: &Arc<std::sync::Mutex<Vec<CapturedRequest>>>, req: &Request<Incoming>) {
1183+
captured.lock().unwrap().push((
1184+
req.method().clone(),
1185+
req.uri().path().to_string(),
1186+
req.uri().query().map(|s| s.to_string()),
1187+
));
1188+
}
1189+
1190+
/// Build an `AmazonS3` via the public builder so that `bucket_endpoint`
1191+
/// is computed by the library from the addressing-style option — i.e.
1192+
/// the option under test actually drives the URL the client emits.
1193+
fn make_store(mock: &MockServer, virtual_hosted: bool, disable_bulk_delete: bool) -> AmazonS3 {
1194+
AmazonS3Builder::new()
1195+
.with_endpoint(mock.url())
1196+
.with_bucket_name("test-bucket")
1197+
.with_region("us-east-1")
1198+
.with_allow_http(true)
1199+
.with_skip_signature(true)
1200+
.with_virtual_hosted_style_request(virtual_hosted)
1201+
.with_disable_bulk_delete(disable_bulk_delete)
1202+
.build()
1203+
.unwrap()
1204+
}
1205+
1206+
#[tokio::test]
1207+
async fn test_delete_default() {
1208+
// Default: path-style + bulk delete enabled.
1209+
// `delete_stream` must issue a single `POST /{bucket}?delete`.
1210+
let mock = MockServer::new().await;
1211+
let captured: Arc<std::sync::Mutex<Vec<CapturedRequest>>> = Default::default();
1212+
let c = Arc::clone(&captured);
1213+
mock.push_fn(move |req| {
1214+
capture(&c, &req);
1215+
Response::builder()
1216+
.status(200)
1217+
.body("<DeleteResult><Deleted><Key>foo</Key></Deleted></DeleteResult>".to_string())
1218+
.unwrap()
1219+
});
1220+
1221+
let store = make_store(&mock, false, false);
1222+
let locations = futures_util::stream::iter(vec![Ok(Path::from("foo"))]).boxed();
1223+
let deleted: Vec<_> = store.delete_stream(locations).try_collect().await.unwrap();
1224+
assert_eq!(deleted.len(), 1);
1225+
1226+
let captured = captured.lock().unwrap().clone();
1227+
assert_eq!(captured.len(), 1, "expected one bulk delete request");
1228+
assert_eq!(captured[0].0, Method::POST);
1229+
assert_eq!(captured[0].1, "/test-bucket");
1230+
assert_eq!(captured[0].2.as_deref(), Some("delete"));
1231+
1232+
mock.shutdown().await;
1233+
}
1234+
1235+
#[tokio::test]
1236+
async fn test_delete_default_with_disable_bulk() {
1237+
// Path-style + bulk delete disabled.
1238+
// `delete_stream` must fan out into `DELETE /{bucket}/{key}` (one per
1239+
// object, no `?delete` query).
1240+
let mock = MockServer::new().await;
1241+
let captured: Arc<std::sync::Mutex<Vec<CapturedRequest>>> = Default::default();
1242+
for _ in 0..2 {
1243+
let c = Arc::clone(&captured);
1244+
mock.push_fn(move |req| {
1245+
capture(&c, &req);
1246+
Response::builder().status(204).body(String::new()).unwrap()
1247+
});
1248+
}
1249+
1250+
let store = make_store(&mock, false, true);
1251+
let locations =
1252+
futures_util::stream::iter(vec![Ok(Path::from("foo")), Ok(Path::from("bar"))]).boxed();
1253+
let deleted: Vec<_> = store.delete_stream(locations).try_collect().await.unwrap();
1254+
assert_eq!(deleted.len(), 2);
1255+
1256+
let mut captured = captured.lock().unwrap().clone();
1257+
captured.sort_by(|a, b| a.1.cmp(&b.1));
1258+
assert_eq!(captured.len(), 2, "expected one DELETE per object");
1259+
assert_eq!(
1260+
captured[0],
1261+
(Method::DELETE, "/test-bucket/bar".to_string(), None)
1262+
);
1263+
assert_eq!(
1264+
captured[1],
1265+
(Method::DELETE, "/test-bucket/foo".to_string(), None)
1266+
);
1267+
1268+
mock.shutdown().await;
1269+
}
1270+
1271+
#[tokio::test]
1272+
async fn test_delete_virtual_hosted() {
1273+
// Virtual-hosted style + bulk delete enabled.
1274+
// `delete_stream` must issue a single `POST /?delete` (bucket is in
1275+
// the host, not the path).
1276+
let mock = MockServer::new().await;
1277+
let captured: Arc<std::sync::Mutex<Vec<CapturedRequest>>> = Default::default();
1278+
let c = Arc::clone(&captured);
1279+
mock.push_fn(move |req| {
1280+
capture(&c, &req);
1281+
Response::builder()
1282+
.status(200)
1283+
.body("<DeleteResult><Deleted><Key>foo</Key></Deleted></DeleteResult>".to_string())
1284+
.unwrap()
1285+
});
1286+
1287+
let store = make_store(&mock, true, false);
1288+
let locations = futures_util::stream::iter(vec![Ok(Path::from("foo"))]).boxed();
1289+
let deleted: Vec<_> = store.delete_stream(locations).try_collect().await.unwrap();
1290+
assert_eq!(deleted.len(), 1);
1291+
1292+
let captured = captured.lock().unwrap().clone();
1293+
assert_eq!(captured.len(), 1, "expected one bulk delete request");
1294+
assert_eq!(captured[0].0, Method::POST);
1295+
assert_eq!(captured[0].1, "/");
1296+
assert_eq!(captured[0].2.as_deref(), Some("delete"));
1297+
1298+
mock.shutdown().await;
1299+
}
1300+
1301+
#[tokio::test]
1302+
async fn test_delete_virtual_hosted_with_disable_bulk() {
1303+
// Virtual-hosted style + bulk delete disabled.
1304+
// `delete_stream` must fan out into `DELETE /{key}` (no bucket in
1305+
// path, no `?delete` query).
1306+
let mock = MockServer::new().await;
1307+
let captured: Arc<std::sync::Mutex<Vec<CapturedRequest>>> = Default::default();
1308+
for _ in 0..2 {
1309+
let c = Arc::clone(&captured);
1310+
mock.push_fn(move |req| {
1311+
capture(&c, &req);
1312+
Response::builder().status(204).body(String::new()).unwrap()
1313+
});
1314+
}
1315+
1316+
let store = make_store(&mock, true, true);
1317+
let locations =
1318+
futures_util::stream::iter(vec![Ok(Path::from("foo")), Ok(Path::from("bar"))]).boxed();
1319+
let deleted: Vec<_> = store.delete_stream(locations).try_collect().await.unwrap();
1320+
assert_eq!(deleted.len(), 2);
1321+
1322+
let mut captured = captured.lock().unwrap().clone();
1323+
captured.sort_by(|a, b| a.1.cmp(&b.1));
1324+
assert_eq!(captured.len(), 2, "expected one DELETE per object");
1325+
assert_eq!(captured[0], (Method::DELETE, "/bar".to_string(), None));
1326+
assert_eq!(captured[1], (Method::DELETE, "/foo".to_string(), None));
1327+
1328+
mock.shutdown().await;
1329+
}
1330+
11581331
#[tokio::test]
11591332
async fn test_default_headers_signed_get_request() {
11601333
let mock = MockServer::new().await;

src/aws/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,25 @@ impl ObjectStore for AmazonS3 {
264264
locations: BoxStream<'static, Result<Path>>,
265265
) -> BoxStream<'static, Result<Path>> {
266266
let client = Arc::clone(&self.client);
267+
268+
// Some S3-compatible providers do not implement
269+
// the bulk `DeleteObjects` API (`POST /?delete`). When bulk delete is
270+
// disabled, fall back to parallel single-object `DELETE /key` requests,
271+
// which are part of the core S3 API supported by every provider.
272+
if client.config.disable_bulk_delete {
273+
return locations
274+
.map(move |location| {
275+
let client = Arc::clone(&client);
276+
async move {
277+
let location = location?;
278+
client.delete_request(&location).await?;
279+
Ok(location)
280+
}
281+
})
282+
.buffered(20)
283+
.boxed();
284+
}
285+
267286
locations
268287
.try_chunks(1_000)
269288
.map(move |locations| {

0 commit comments

Comments
 (0)