fix: stabilize s3-tests delete key-limit coverage (#4283)

* fix: tighten list handling and s3 test support

* chore: tidy imports and metric updates

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Zhengchao An <anzhengchao@gmail.com>

* fix: address s3tests review follow-ups

---------

Signed-off-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
houseme
2026-07-05 20:35:32 +08:00
committed by GitHub
parent 46bb7e52b6
commit 76124423a4
8 changed files with 356 additions and 50 deletions
+1 -1
View File
@@ -247,7 +247,7 @@ impl AuditRuntimeView {
registry.list_targets()
}
pub async fn get_target_values(&self) -> Vec<rustfs_targets::SharedTarget<AuditEntry>> {
pub async fn get_target_values(&self) -> Vec<SharedTarget<AuditEntry>> {
let registry = self.registry.lock().await;
registry.list_target_values()
}
+5 -5
View File
@@ -112,8 +112,8 @@ impl AuditRegistry {
/// * `id` - The identifier for the target to be removed.
///
/// # Returns
/// * `Option<Box<dyn Target<AuditEntry> + Send + Sync>>` - The removed target if it existed.
pub async fn remove_target(&mut self, id: &str) -> Option<rustfs_targets::SharedTarget<AuditEntry>> {
/// * `Option<SharedTarget<AuditEntry>>` - The removed target if it existed.
pub async fn remove_target(&mut self, id: &str) -> Option<SharedTarget<AuditEntry>> {
self.targets.remove_and_close(id).await
}
@@ -123,13 +123,13 @@ impl AuditRegistry {
/// * `id` - The identifier for the target to be retrieved.
///
/// # Returns
/// * `Option<&(dyn Target<AuditEntry> + Send + Sync)>` - The target if it exists.
pub fn get_target(&self, id: &str) -> Option<rustfs_targets::SharedTarget<AuditEntry>> {
/// * `Option<SharedTarget<AuditEntry>>` - The target if it exists.
pub fn get_target(&self, id: &str) -> Option<SharedTarget<AuditEntry>> {
self.targets.get(id)
}
/// Lists cloned target values for runtime inspection without exposing mutable registry access.
pub fn list_target_values(&self) -> Vec<rustfs_targets::SharedTarget<AuditEntry>> {
pub fn list_target_values(&self) -> Vec<SharedTarget<AuditEntry>> {
self.targets.values()
}
+6 -6
View File
@@ -1913,7 +1913,7 @@ impl Metrics {
let duration_millis = duration_millis_saturated(duration);
let _ = self
.scanner_yield_duration_millis
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(duration_millis))
});
}
@@ -1927,7 +1927,7 @@ impl Metrics {
let duration_millis = duration_millis_saturated(duration);
let _ = self
.scanner_throttle_sleep_duration_millis
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(duration_millis))
});
}
@@ -2239,10 +2239,10 @@ impl Metrics {
active: Option<usize>,
) {
let key = format!("{pool}/{set}");
let mut states = match self.scanner_disk_bucket_scan_states.lock() {
Ok(states) => states,
Err(poisoned) => poisoned.into_inner(),
};
let mut states = self
.scanner_disk_bucket_scan_states
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let state = states.entry(key).or_default();
if let Some(concurrency_limit) = concurrency_limit {
state.concurrency_limit = concurrency_limit as u64;
+2 -2
View File
@@ -19,9 +19,9 @@ use time::OffsetDateTime;
use time::format_description;
use time::format_description::well_known::Rfc3339;
static LEGACY_FORMAT: std::sync::OnceLock<time::format_description::OwnedFormatItem> = std::sync::OnceLock::new();
static LEGACY_FORMAT: std::sync::OnceLock<format_description::OwnedFormatItem> = std::sync::OnceLock::new();
fn legacy_format() -> &'static time::format_description::OwnedFormatItem {
fn legacy_format() -> &'static format_description::OwnedFormatItem {
LEGACY_FORMAT.get_or_init(|| {
format_description::parse_owned::<2>(
"[year]-[month]-[day] [hour]:[minute]:[second].[subsecond] [offset_hour sign:mandatory]:[offset_minute]:[offset_second]",
+178 -36
View File
@@ -117,6 +117,65 @@ enum GatherResultsState {
InputClosed,
}
#[derive(Clone)]
struct ListPathLogContext {
bucket: String,
base_dir: String,
prefix: String,
marker: String,
limit: i32,
}
impl ListPathLogContext {
fn from_options(opts: &ListPathOptions) -> Self {
Self {
bucket: opts.bucket.clone(),
base_dir: opts.base_dir.clone(),
prefix: opts.prefix.clone(),
marker: opts.marker.clone().unwrap_or_default(),
limit: opts.limit,
}
}
}
fn log_list_path_worker_error<E>(component: &'static str, stage: &'static str, context: &ListPathLogContext, err: &E)
where
E: std::fmt::Display + ?Sized,
{
error!(
component,
stage,
bucket = %context.bucket,
prefix = %context.prefix,
base_dir = %context.base_dir,
limit = context.limit,
marker = %context.marker,
error = %err,
"list_path worker failed"
);
}
fn log_list_path_finished(
component: &'static str,
context: &ListPathLogContext,
elapsed_ms: f64,
candidate_count: usize,
has_error: bool,
) {
debug!(
component,
bucket = %context.bucket,
prefix = %context.prefix,
base_dir = %context.base_dir,
limit = context.limit,
marker = %context.marker,
candidate_count,
has_error,
elapsed_ms,
"list_path finished"
);
}
#[derive(Debug, Default, Clone)]
pub struct ListPathOptions {
pub id: Option<String>,
@@ -1316,6 +1375,7 @@ impl ECStore {
if o.transient {
o.create = false;
}
let log_context = ListPathLogContext::from_options(&o);
// cancel channel
let cancel = CancellationToken::new();
@@ -1329,6 +1389,7 @@ impl ECStore {
let cancel_rx1 = cancel.clone();
let cancel_rx1_for_err = cancel_rx1.clone();
let err_tx1 = err_tx.clone();
let job1_context = log_context.clone();
let job1 = tokio::spawn(
async move {
let mut opts = opts;
@@ -1336,7 +1397,7 @@ impl ECStore {
if let Err(err) = store.list_merged(cancel_rx1, opts, sender).await
&& !cancel_rx1_for_err.is_cancelled()
{
error!("list_merged err {:?}", err);
log_list_path_worker_error("store", "list_merged", &job1_context, &err);
let _ = err_tx1.send(Arc::new(err));
}
}
@@ -1348,13 +1409,14 @@ impl ECStore {
let (result_tx, mut result_rx) = mpsc::channel(1);
let err_tx2 = err_tx.clone();
let opts = o.clone();
let job2_context = log_context.clone();
let job2 = tokio::spawn(
async move {
match gather_results(cancel_rx2, opts, recv, result_tx).await {
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
Ok(GatherResultsState::InputClosed) => {}
Err(err) => {
error!("gather_results err {:?}", err);
log_list_path_worker_error("store", "gather_results", &job2_context, &err);
let _ = err_tx2.send(Arc::new(err));
cancel.cancel();
}
@@ -1369,12 +1431,12 @@ impl ECStore {
res = err_rx.recv() =>{
match res{
Ok(o) => {
error!("list_path err_rx.recv() ok {:?}", &o);
MetaCacheEntriesSortedResult{ entries: None, err: Some(o.as_ref().clone().into()) }
Ok(err) => {
log_list_path_worker_error("store", "worker_error", &log_context, err.as_ref());
MetaCacheEntriesSortedResult{ entries: None, err: Some(err.as_ref().clone().into()) }
},
Err(err) => {
error!("list_path err_rx.recv() err {:?}", &err);
log_list_path_worker_error("store", "error_channel_closed", &log_context, &err);
MetaCacheEntriesSortedResult{ entries: None, err: Some(rustfs_filemeta::Error::other(err)) }
},
@@ -1390,11 +1452,12 @@ impl ECStore {
join_all(vec![job1, job2]).await;
if let Ok(err) = err_rx.try_recv() {
error!("list_path err_rx.try_recv() ok {:?}", &err);
log_list_path_worker_error("store", "trailing_worker_error", &log_context, err.as_ref());
result.err = Some(err.as_ref().clone().into());
}
if result.err.is_some() {
log_list_path_finished("store", &log_context, list_path_started.elapsed().as_secs_f64() * 1000.0, 0, true);
return Ok(result);
}
@@ -1420,14 +1483,16 @@ impl ECStore {
list_path_started.elapsed().as_secs_f64() * 1000.0,
);
debug!(
bucket = %o.bucket,
prefix = %o.base_dir,
limit = o.limit,
marker = %o.marker.as_deref().unwrap_or(""),
candidate_count = result.entries.as_ref().map(|entries| entries.entries().len()).unwrap_or_default(),
has_error = result.err.is_some(),
"store list_path finished"
log_list_path_finished(
"store",
&log_context,
list_path_started.elapsed().as_secs_f64() * 1000.0,
result
.entries
.as_ref()
.map(|entries| entries.entries().len())
.unwrap_or_default(),
result.err.is_some(),
);
Ok(result)
@@ -1997,7 +2062,7 @@ async fn gather_results(
results_tx
.send(MetaCacheEntriesSortedResult {
entries: Some(MetaCacheEntriesSorted {
o: MetaCacheEntries(entries.clone()),
o: MetaCacheEntries(entries),
..Default::default()
}),
err: None,
@@ -2024,7 +2089,7 @@ async fn gather_results(
results_tx
.send(MetaCacheEntriesSortedResult {
entries: Some(MetaCacheEntriesSorted {
o: MetaCacheEntries(entries.clone()),
o: MetaCacheEntries(entries),
..Default::default()
}),
err: Some(Error::Unexpected.into()),
@@ -2448,6 +2513,7 @@ impl Sets {
pub async fn list_path(self: Arc<Self>, o: &ListPathOptions) -> Result<MetaCacheEntriesSortedResult> {
check_list_objs_args(&o.bucket, &o.prefix, &o.marker)?;
let list_path_started = std::time::Instant::now();
let mut o = o.clone();
o.marker = o.marker.filter(|v| v >= &o.prefix);
@@ -2488,6 +2554,7 @@ impl Sets {
if o.transient {
o.create = false;
}
let log_context = ListPathLogContext::from_options(&o);
let cancel = CancellationToken::new();
let (err_tx, mut err_rx) = broadcast::channel::<Arc<Error>>(1);
@@ -2498,6 +2565,7 @@ impl Sets {
let cancel_rx1 = cancel.clone();
let cancel_rx1_for_err = cancel_rx1.clone();
let err_tx1 = err_tx.clone();
let job1_context = log_context.clone();
let job1 = tokio::spawn(
async move {
let mut opts = opts;
@@ -2505,7 +2573,7 @@ impl Sets {
if let Err(err) = sets.list_merged(cancel_rx1, opts, sender).await
&& !cancel_rx1_for_err.is_cancelled()
{
error!("list_merged err {:?}", err);
log_list_path_worker_error("sets", "list_merged", &job1_context, &err);
let _ = err_tx1.send(Arc::new(err));
}
}
@@ -2516,13 +2584,14 @@ impl Sets {
let (result_tx, mut result_rx) = mpsc::channel(1);
let err_tx2 = err_tx.clone();
let opts = o.clone();
let job2_context = log_context.clone();
let job2 = tokio::spawn(
async move {
match gather_results(cancel_rx2, opts, recv, result_tx).await {
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
Ok(GatherResultsState::InputClosed) => {}
Err(err) => {
error!("gather_results err {:?}", err);
log_list_path_worker_error("sets", "gather_results", &job2_context, &err);
let _ = err_tx2.send(Arc::new(err));
cancel.cancel();
}
@@ -2534,8 +2603,14 @@ impl Sets {
let mut result = tokio::select! {
res = err_rx.recv() => {
match res {
Ok(err) => MetaCacheEntriesSortedResult { entries: None, err: Some(err.as_ref().clone().into()) },
Err(err) => MetaCacheEntriesSortedResult { entries: None, err: Some(rustfs_filemeta::Error::other(err)) },
Ok(err) => {
log_list_path_worker_error("sets", "worker_error", &log_context, err.as_ref());
MetaCacheEntriesSortedResult { entries: None, err: Some(err.as_ref().clone().into()) }
},
Err(err) => {
log_list_path_worker_error("sets", "error_channel_closed", &log_context, &err);
MetaCacheEntriesSortedResult { entries: None, err: Some(rustfs_filemeta::Error::other(err)) }
},
}
}
Some(result) = result_rx.recv() => result,
@@ -2544,10 +2619,12 @@ impl Sets {
join_all(vec![job1, job2]).await;
if let Ok(err) = err_rx.try_recv() {
log_list_path_worker_error("sets", "trailing_worker_error", &log_context, err.as_ref());
result.err = Some(err.as_ref().clone().into());
}
if result.err.is_some() {
log_list_path_finished("sets", &log_context, list_path_started.elapsed().as_secs_f64() * 1000.0, 0, true);
return Ok(result);
}
@@ -2568,6 +2645,18 @@ impl Sets {
}
}
log_list_path_finished(
"sets",
&log_context,
list_path_started.elapsed().as_secs_f64() * 1000.0,
result
.entries
.as_ref()
.map(|entries| entries.entries().len())
.unwrap_or_default(),
result.err.is_some(),
);
Ok(result)
}
@@ -3385,6 +3474,7 @@ impl SetDisks {
if o.transient {
o.create = false;
}
let log_context = ListPathLogContext::from_options(&o);
let cancel = CancellationToken::new();
let (err_tx, mut err_rx) = broadcast::channel::<Arc<Error>>(1);
@@ -3395,6 +3485,7 @@ impl SetDisks {
let cancel_rx1 = cancel.clone();
let cancel_rx1_for_err = cancel_rx1.clone();
let err_tx1 = err_tx.clone();
let job1_context = log_context.clone();
let job1 = tokio::spawn(
async move {
let mut opts = opts;
@@ -3402,7 +3493,7 @@ impl SetDisks {
if let Err(err) = set.list_path(cancel_rx1, opts, sender).await
&& !cancel_rx1_for_err.is_cancelled()
{
error!("list_path err {:?}", err);
log_list_path_worker_error("set_disks", "list_path", &job1_context, &err);
let _ = err_tx1.send(Arc::new(err));
}
}
@@ -3413,13 +3504,14 @@ impl SetDisks {
let (result_tx, mut result_rx) = mpsc::channel(1);
let err_tx2 = err_tx.clone();
let opts = o.clone();
let job2_context = log_context.clone();
let job2 = tokio::spawn(
async move {
match gather_results(cancel_rx2, opts, recv, result_tx).await {
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
Ok(GatherResultsState::InputClosed) => {}
Err(err) => {
error!("gather_results err {:?}", err);
log_list_path_worker_error("set_disks", "gather_results", &job2_context, &err);
let _ = err_tx2.send(Arc::new(err));
cancel.cancel();
}
@@ -3431,8 +3523,14 @@ impl SetDisks {
let mut result = tokio::select! {
res = err_rx.recv() => {
match res {
Ok(err) => MetaCacheEntriesSortedResult { entries: None, err: Some(err.as_ref().clone().into()) },
Err(err) => MetaCacheEntriesSortedResult { entries: None, err: Some(rustfs_filemeta::Error::other(err)) },
Ok(err) => {
log_list_path_worker_error("set_disks", "worker_error", &log_context, err.as_ref());
MetaCacheEntriesSortedResult { entries: None, err: Some(err.as_ref().clone().into()) }
},
Err(err) => {
log_list_path_worker_error("set_disks", "error_channel_closed", &log_context, &err);
MetaCacheEntriesSortedResult { entries: None, err: Some(rustfs_filemeta::Error::other(err)) }
},
}
}
Some(result) = result_rx.recv() => result,
@@ -3441,10 +3539,12 @@ impl SetDisks {
join_all(vec![job1, job2]).await;
if let Ok(err) = err_rx.try_recv() {
log_list_path_worker_error("set_disks", "trailing_worker_error", &log_context, err.as_ref());
result.err = Some(err.as_ref().clone().into());
}
if result.err.is_some() {
log_list_path_finished("set_disks", &log_context, list_path_started.elapsed().as_secs_f64() * 1000.0, 0, true);
return Ok(result);
}
@@ -3470,14 +3570,16 @@ impl SetDisks {
list_path_started.elapsed().as_secs_f64() * 1000.0,
);
debug!(
bucket = %o.bucket,
prefix = %o.base_dir,
limit = o.limit,
marker = %o.marker.as_deref().unwrap_or(""),
candidate_count = result.entries.as_ref().map(|entries| entries.entries().len()).unwrap_or_default(),
has_error = result.err.is_some(),
"sets list_path finished"
log_list_path_finished(
"set_disks",
&log_context,
list_path_started.elapsed().as_secs_f64() * 1000.0,
result
.entries
.as_ref()
.map(|entries| entries.entries().len())
.unwrap_or_default(),
result.err.is_some(),
);
Ok(result)
@@ -3601,10 +3703,18 @@ impl SetDisks {
let resolver = agreed_resolver.clone();
let supplement = agreed_supplement.clone();
async move {
if cancel_token.is_cancelled() {
return;
}
let entry =
match resolve_agreed_listing_entry(entry, reader_disks, resolver.clone(), enforce_write_quorum) {
ListingEntryResolution::Resolved(entry) => entry,
ListingEntryResolution::NeedsSupplement(entry, fallback) => {
if cancel_token.is_cancelled() {
return;
}
let Some(entry) = resolve_agreed_listing_entry_with_supplement(
entry,
reader_disks,
@@ -3621,7 +3731,7 @@ impl SetDisks {
ListingEntryResolution::Rejected => return,
};
if let Err(err) = value.send(entry).await
if let Err(err) = send_or_cancel(&cancel_token, &value, entry).await
&& !cancel_token.is_cancelled()
{
error!("list_path send fail {:?}", err);
@@ -3636,9 +3746,13 @@ impl SetDisks {
let cancel_token = cancel_for_send2.clone();
let supplement = partial_supplement.clone();
async move {
if cancel_token.is_cancelled() {
return;
}
if let Some(entry) =
resolve_listing_entries_with_supplement(entries, resolver, enforce_write_quorum, supplement).await
&& let Err(err) = value.send(entry).await
&& let Err(err) = send_or_cancel(&cancel_token, &value, entry).await
&& !cancel_token.is_cancelled()
{
error!("list_path send fail {:?}", err);
@@ -3667,6 +3781,9 @@ impl SetDisks {
asked_disks = ask_disks,
listing_quorum = listing_quorum,
stop_disk_at_limit = opts.stop_disk_at_limit,
limit = opts.limit,
per_disk_limit = limit,
elapsed_ms = list_path_started.elapsed().as_secs_f64() * 1000.0,
list_error = %err,
"set_disks list_path finished with error"
);
@@ -3679,6 +3796,9 @@ impl SetDisks {
asked_disks = ask_disks,
listing_quorum = listing_quorum,
stop_disk_at_limit = opts.stop_disk_at_limit,
limit = opts.limit,
per_disk_limit = limit,
elapsed_ms = list_path_started.elapsed().as_secs_f64() * 1000.0,
"set_disks list_path finished"
);
}
@@ -3755,7 +3875,7 @@ mod test {
fallback_entries_for_object, gather_results, latest_listing_allow_agreed_objects, latest_listing_object_quorum,
latest_listing_raw_min_disks, latest_listing_required_object_quorum, list_metadata_resolution_params,
list_objects_quorum_from_env, list_quorum_from_env, max_keys_plus_one, merge_entry_channels, normalize_list_quorum,
parse_version_marker, resolve_agreed_listing_entry, resolve_listing_entries, version_marker_for_entries,
parse_version_marker, resolve_agreed_listing_entry, resolve_listing_entries, send_or_cancel, version_marker_for_entries,
walk_result_from_set_errors,
};
use crate::cache_value::metacache_set::{FallbackClaimTracker, TestReaderBehavior, list_path_raw};
@@ -5188,4 +5308,26 @@ mod test {
drop(tx_a);
drop(tx_b);
}
#[tokio::test]
async fn send_or_cancel_returns_false_when_cancelled_while_output_is_full() {
let (out_tx, _out_rx) = mpsc::channel::<MetaCacheEntry>(1);
out_tx
.send(test_meta_entry("already-buffered"))
.await
.expect("output channel should accept initial entry");
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move { send_or_cancel(&cancel_clone, &out_tx, test_meta_entry("next")).await });
cancel.cancel();
let result = timeout(Duration::from_secs(2), handle)
.await
.expect("send_or_cancel should not hang when cancellation wins over backpressure")
.expect("task should not panic")
.expect("send_or_cancel should succeed");
assert!(!result, "send_or_cancel should report cancellation instead of a successful send");
}
}
+22
View File
@@ -8333,6 +8333,28 @@ mod tests {
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[tokio::test]
async fn execute_delete_objects_rejects_more_than_one_thousand_objects_before_store_lookup() {
let objects = (0..1001)
.map(|idx| ObjectIdentifier {
key: format!("test-key-{idx}"),
version_id: None,
..Default::default()
})
.collect();
let input = DeleteObjectsInput::builder()
.bucket("test-bucket".to_string())
.delete(Delete { objects, quiet: None })
.build()
.expect("delete objects input should build");
let req = build_request(input, Method::POST);
let usecase = DefaultObjectUsecase::without_context();
let err = usecase.execute_delete_objects(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[tokio::test]
async fn execute_delete_objects_returns_internal_error_when_store_uninitialized() {
let input = DeleteObjectsInput::builder()
@@ -0,0 +1,75 @@
diff --git a/s3tests/functional/test_s3.py b/s3tests/functional/test_s3.py
index 2ee91b3..bf2f2b1 100644
--- a/s3tests/functional/test_s3.py
+++ b/s3tests/functional/test_s3.py
@@ -138,6 +138,9 @@ def _create_objects(bucket=None, bucket_name=None, keys=[], put_object_args={}):
return bucket_name
+def _utcnow_naive():
+ return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
+
def _get_keys(response):
"""
return lists of strings that are the keys from a client.list_objects() response
@@ -1760,15 +1763,8 @@ def test_multi_objectv2_delete():
def test_multi_object_delete_key_limit():
key_names = [f"key-{i}" for i in range(1001)]
- bucket_name = _create_objects(keys=key_names)
client = get_client()
-
- paginator = client.get_paginator('list_objects')
- pages = paginator.paginate(Bucket=bucket_name)
- numKeys = 0
- for page in pages:
- numKeys += len(page['Contents'])
- assert numKeys == 1001
+ bucket_name = get_new_bucket(client)
objs_dict = _make_objs_dict(key_names=key_names)
e = assert_raises(ClientError,client.delete_objects,Bucket=bucket_name,Delete=objs_dict)
@@ -1777,15 +1773,8 @@ def test_multi_object_delete_key_limit():
def test_multi_objectv2_delete_key_limit():
key_names = [f"key-{i}" for i in range(1001)]
- bucket_name = _create_objects(keys=key_names)
client = get_client()
-
- paginator = client.get_paginator('list_objects_v2')
- pages = paginator.paginate(Bucket=bucket_name)
- numKeys = 0
- for page in pages:
- numKeys += len(page['Contents'])
- assert numKeys == 1001
+ bucket_name = get_new_bucket(client)
objs_dict = _make_objs_dict(key_names=key_names)
e = assert_raises(ClientError,client.delete_objects,Bucket=bucket_name,Delete=objs_dict)
@@ -9163,7 +9152,7 @@ def test_lifecycle_expiration_header_put():
bucket_name = get_new_bucket()
client = get_client()
- now = datetime.datetime.utcnow()
+ now = _utcnow_naive()
response = setup_lifecycle_expiration(
client, bucket_name, 'rule1', 1, 'days1/')
assert check_lifecycle_expiration_header(response, now, 'rule1', 1)
@@ -9175,7 +9164,7 @@ def test_lifecycle_expiration_header_head():
bucket_name = get_new_bucket()
client = get_client()
- now = datetime.datetime.utcnow()
+ now = _utcnow_naive()
response = setup_lifecycle_expiration(
client, bucket_name, 'rule1', 1, 'days1/')
@@ -9246,7 +9235,7 @@ def test_lifecycle_expiration_header_tags_head():
@pytest.mark.lifecycle_expiration
@pytest.mark.fails_on_dbstore
def test_lifecycle_expiration_header_and_tags_head():
- now = datetime.datetime.utcnow()
+ now = _utcnow_naive()
bucket_name = get_new_bucket()
client = get_client()
lifecycle={
+67
View File
@@ -96,6 +96,55 @@ log_error() {
echo -e "${RED}[ERROR]${NC} $*"
}
summarize_junit_failures() {
local junit_path="$1"
if [ ! -f "${junit_path}" ]; then
log_warn "JUnit report not found: ${junit_path}"
return 0
fi
python3 - "${junit_path}" <<'PY'
import sys
import xml.etree.ElementTree as ET
junit_path = sys.argv[1]
try:
root = ET.parse(junit_path).getroot()
except Exception as exc:
print(f"[WARN] Failed to parse JUnit report {junit_path}: {exc}")
raise SystemExit(0)
failures = []
for case in root.iter("testcase"):
failure = case.find("failure")
error = case.find("error")
node = failure if failure is not None else error
if node is None:
continue
classname = case.attrib.get("classname", "")
name = case.attrib.get("name", "")
duration = case.attrib.get("time", "0")
message = node.attrib.get("message") or (node.text or "").strip().splitlines()[0:1]
if isinstance(message, list):
message = message[0] if message else ""
failures.append((classname, name, duration, message))
if not failures:
print("[INFO] No failed testcases found in JUnit report")
raise SystemExit(0)
print("[ERROR] s3-tests failed testcase summary:")
for classname, name, duration, message in failures[:20]:
nodeid = f"{classname}::{name}" if classname else name
print(f"[ERROR] - {nodeid} ({duration}s): {message}")
if len(failures) > 20:
print(f"[ERROR] - ... {len(failures) - 20} additional failed testcases omitted")
PY
}
# =============================================================================
# Test Classification Files
# =============================================================================
@@ -898,6 +947,18 @@ git -C "${PROJECT_ROOT}/s3-tests" checkout -qf --detach "${S3TESTS_REV}" || {
exit 1
}
S3TESTS_PATCH_DIR="${SCRIPT_DIR}/patches"
if [ -d "${S3TESTS_PATCH_DIR}" ]; then
for patch_file in "${S3TESTS_PATCH_DIR}"/*.patch; do
[ -e "${patch_file}" ] || continue
log_info "Applying s3-tests patch: ${patch_file##*/}"
git -C "${PROJECT_ROOT}/s3-tests" apply "${patch_file}" || {
log_error "Failed to apply s3-tests patch: ${patch_file}"
exit 1
}
done
fi
cd "${PROJECT_ROOT}/s3-tests"
# Install tox if not available
@@ -940,6 +1001,7 @@ else
fi
# Run tests from s3tests/functional
set +e
S3TEST_CONF="${CONF_OUTPUT_PATH}" \
tox -- \
-vv -ra --showlocals --tb=long \
@@ -951,6 +1013,7 @@ S3TEST_CONF="${CONF_OUTPUT_PATH}" \
2>&1 | tee "${ARTIFACTS_DIR}/pytest.log"
TEST_EXIT_CODE=${PIPESTATUS[0]}
set -e
# Step 10: Collect RustFS logs
log_info "Collecting RustFS logs..."
@@ -977,6 +1040,10 @@ if [ -f "${REPORT_SCRIPT}" ] && [ -f "${ARTIFACTS_DIR}/junit.xml" ]; then
|| log_warn "Compatibility report generation failed"
fi
if [ ${TEST_EXIT_CODE} -ne 0 ]; then
summarize_junit_failures "${ARTIFACTS_DIR}/junit.xml"
fi
# Summary
if [ ${TEST_EXIT_CODE} -eq 0 ]; then
log_info "Tests completed successfully!"