mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 12:26:37 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c57f22c3a0 | |||
| 7143697a5f | |||
| a34310a58f | |||
| 2e60029079 | |||
| 2c3e68ad89 | |||
| 2f0918f60b | |||
| 5b951de2b7 |
@@ -400,7 +400,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -440,7 +440,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -470,7 +470,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
# On a PR, one failing protocol leg is enough to know the PR is not ready,
|
||||
# so stop the sibling leg instead of paying another ~40 minutes for it.
|
||||
|
||||
@@ -57,6 +57,13 @@ pub const DEFAULT_MAX_IO_EVENTS_PER_TICK: usize = 1024;
|
||||
pub const DEFAULT_EVENT_INTERVAL: u32 = 61;
|
||||
pub const DEFAULT_RNG_SEED: Option<u64> = None; // None means random
|
||||
|
||||
/// Dedicated blocking thread pool for fsync/fdatasync operations.
|
||||
/// When > 1, fsync operations are isolated from the main blocking pool to
|
||||
/// prevent device-bound fsync from starving read operations (pread/stat/open).
|
||||
/// Default 0 means auto (no isolation, use main runtime).
|
||||
pub const ENV_FSYNC_BLOCKING_THREADS: &str = "RUSTFS_RUNTIME_FSYNC_BLOCKING_THREADS";
|
||||
pub const DEFAULT_FSYNC_BLOCKING_THREADS: usize = 0;
|
||||
|
||||
// Dial9 Tokio Telemetry Default values
|
||||
pub const DEFAULT_RUNTIME_DIAL9_ENABLED: bool = false; // Disabled by default
|
||||
pub const DEFAULT_RUNTIME_DIAL9_OUTPUT_DIR: &str = "/var/log/rustfs/telemetry";
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
use crate::common::RustFSTestClusterEnvironment;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::types::{CorsConfiguration, CorsRule};
|
||||
use bytes::Bytes;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Barrier;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const BUCKET: &str = "conditional-put-race-bucket";
|
||||
const BUCKET_METADATA_RELOAD_BUCKET: &str = "bucket-metadata-reload-barrier";
|
||||
|
||||
async fn cleanup_object(client: &Client, key: &str) {
|
||||
if let Err(e) = client.delete_object().bucket(BUCKET).key(key).send().await {
|
||||
@@ -28,6 +30,16 @@ async fn cleanup_object(client: &Client, key: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_bucket_cors_missing(client: &Client) {
|
||||
let result = client.get_bucket_cors().bucket(BUCKET_METADATA_RELOAD_BUCKET).send().await;
|
||||
match result {
|
||||
Err(SdkError::ServiceError(error)) => {
|
||||
assert_eq!(error.err().meta().code(), Some("NoSuchCORSConfiguration"));
|
||||
}
|
||||
result => panic!("expected the peer to report a missing CORS configuration: {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn conditional_put(
|
||||
client: &Client,
|
||||
key: &str,
|
||||
@@ -236,3 +248,48 @@ async fn test_conditional_put_basic_cluster() -> Result<(), Box<dyn std::error::
|
||||
cleanup_object(&client, test_key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
crate::common::init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(BUCKET_METADATA_RELOAD_BUCKET).await?;
|
||||
|
||||
let writer = cluster.create_s3_client(0)?;
|
||||
let reader = cluster.create_s3_client(1)?;
|
||||
assert_bucket_cors_missing(&reader).await;
|
||||
|
||||
let rule = CorsRule::builder()
|
||||
.allowed_methods("GET")
|
||||
.allowed_origins("https://example.com")
|
||||
.build()?;
|
||||
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
|
||||
|
||||
writer
|
||||
.put_bucket_cors()
|
||||
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
||||
.cors_configuration(configuration)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let response = reader.get_bucket_cors().bucket(BUCKET_METADATA_RELOAD_BUCKET).send().await?;
|
||||
let rules = response.cors_rules();
|
||||
assert_eq!(
|
||||
rules.len(),
|
||||
1,
|
||||
"peer should observe the committed CORS rule before the write response returns"
|
||||
);
|
||||
assert_eq!(rules[0].allowed_methods(), ["GET"]);
|
||||
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
|
||||
|
||||
writer
|
||||
.delete_bucket_cors()
|
||||
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
||||
.send()
|
||||
.await?;
|
||||
assert_bucket_cors_missing(&reader).await;
|
||||
writer.delete_bucket().bucket(BUCKET_METADATA_RELOAD_BUCKET).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
|
||||
const PEER_REST_RECOVERY_MAX_BACKOFF: Duration = Duration::from_secs(30);
|
||||
const SCANNER_ACTIVITY_MAX_MESSAGE_SIZE: usize = 1024;
|
||||
const REPLICATION_STATS_MAX_MESSAGE_SIZE: usize = 8 * 1024 * 1024;
|
||||
const BUCKET_METADATA_RELOAD_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Error for a peer that reported `success = false` without an `error_info` payload.
|
||||
///
|
||||
@@ -1328,27 +1329,38 @@ impl PeerRestClient {
|
||||
}
|
||||
|
||||
pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
let mut client = self.get_client().await?;
|
||||
let mut request = Request::new(LoadBucketMetadataRequest {
|
||||
bucket: bucket.to_string(),
|
||||
scanner_maintenance_change,
|
||||
});
|
||||
set_tonic_mutation_body_digest(&mut request)?;
|
||||
|
||||
let response = client.load_bucket_metadata(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_bucket_metadata", Some(bucket)));
|
||||
}
|
||||
Ok(())
|
||||
let result = tokio::time::timeout(BUCKET_METADATA_RELOAD_TIMEOUT, async {
|
||||
let result = self.load_bucket_metadata_once(bucket, scanner_maintenance_change).await;
|
||||
if let Err(err) = &result
|
||||
&& Self::is_network_like_error(err)
|
||||
{
|
||||
self.prepare_retry().await;
|
||||
return self.load_bucket_metadata_once(bucket, scanner_maintenance_change).await;
|
||||
}
|
||||
.await,
|
||||
)
|
||||
result
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(Error::other(format!("load_bucket_metadata({bucket}) timed out"))));
|
||||
self.finalize_result(result).await
|
||||
}
|
||||
|
||||
async fn load_bucket_metadata_once(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
|
||||
let mut client = self.get_client().await?;
|
||||
let mut request = Request::new(LoadBucketMetadataRequest {
|
||||
bucket: bucket.to_string(),
|
||||
scanner_maintenance_change,
|
||||
});
|
||||
set_tonic_mutation_body_digest(&mut request)?;
|
||||
request.set_timeout(BUCKET_METADATA_RELOAD_TIMEOUT);
|
||||
|
||||
let response = client.load_bucket_metadata(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_bucket_metadata", Some(bucket)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_bucket_metadata(&self, bucket: &str) -> Result<()> {
|
||||
|
||||
+507
-122
@@ -36,7 +36,8 @@ use crate::disk::error::DiskError;
|
||||
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::{
|
||||
StorageError, is_err_bucket_exists, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found,
|
||||
StorageError, is_err_bucket_exists, is_err_bucket_not_found, is_err_object_not_found, is_err_operation_canceled,
|
||||
is_err_version_not_found,
|
||||
};
|
||||
use crate::layout::endpoints::EndpointServerPools;
|
||||
use crate::object_api::{GetObjectReader, ObjectOptions};
|
||||
@@ -89,6 +90,7 @@ const DECOMMISSION_STAGE_SOURCE_CLEANUP: &str = "source_cleanup";
|
||||
const DECOMMISSION_STAGE_ENTRY_FINISHED: &str = "entry_finished";
|
||||
const DECOMMISSION_PROGRESS_SAVE_INTERVAL: Duration = Duration::seconds(30);
|
||||
const DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD: usize = 1000;
|
||||
const DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF: Duration = Duration::seconds(1);
|
||||
const DECOMMISSION_BUCKET_CONCURRENCY_ENV: &str = "RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY";
|
||||
const DECOMMISSION_BUCKET_CONCURRENCY_DEFAULT_CAP: usize = 4;
|
||||
const DECOMMISSION_TARGET_CAPACITY_OVERHEAD_PERCENT: usize = 30;
|
||||
@@ -638,22 +640,6 @@ fn track_decommission_current_object(meta: &mut PoolMeta, idx: usize, bucket: &s
|
||||
track_decommission_current_object_stage(meta, idx, bucket, object, "")
|
||||
}
|
||||
|
||||
fn touch_decommission_progress(meta: &mut PoolMeta, idx: usize) -> Result<()> {
|
||||
let pool_count = meta.pools.len();
|
||||
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
||||
|
||||
let Some(pool) = meta.pools.get_mut(idx) else {
|
||||
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
||||
};
|
||||
let Some(info) = pool.decommission.as_mut() else {
|
||||
return Err(decommission_metadata_not_initialized_error("touch decommission progress"));
|
||||
};
|
||||
|
||||
pool.last_update = OffsetDateTime::now_utc();
|
||||
info.mark_progress_saved();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_decommission_update_after_result(result: Result<bool>) -> Result<bool> {
|
||||
result.map_err(|err| Error::other(format!("decommission metadata update failed: {err}")))
|
||||
}
|
||||
@@ -773,7 +759,76 @@ async fn load_decommission_entry_exact_versions(
|
||||
}
|
||||
|
||||
fn resolve_decommission_check_after_list_result(list_result: Result<()>, entry_error: Option<Error>) -> Result<()> {
|
||||
if let Some(err) = entry_error { Err(err) } else { list_result }
|
||||
match list_result {
|
||||
Ok(()) => entry_error.map_or(Ok(()), Err),
|
||||
Err(list_err) => resolve_decommission_listing_error(Some(list_err), entry_error).map_or(Ok(()), Err),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_decommission_listing_error(listing_error: Option<Error>, entry_error: Option<Error>) -> Option<Error> {
|
||||
match (listing_error, entry_error) {
|
||||
(Some(listing_error), Some(entry_error)) if is_err_operation_canceled(&listing_error) => Some(entry_error),
|
||||
(Some(listing_error), Some(entry_error)) if is_err_operation_canceled(&entry_error) => Some(listing_error),
|
||||
(Some(listing_error), _) => Some(listing_error),
|
||||
(None, entry_error) => entry_error,
|
||||
}
|
||||
}
|
||||
|
||||
fn decommission_unresolved_listing_error(
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
candidate: Option<&str>,
|
||||
candidate_count: usize,
|
||||
disk_error_count: usize,
|
||||
pool_index: usize,
|
||||
set_index: usize,
|
||||
) -> Error {
|
||||
let location = candidate.unwrap_or(prefix);
|
||||
Error::other(format!(
|
||||
"decommission listing could not resolve metadata for {bucket}/{location} on pool {pool_index} set {set_index} ({candidate_count} candidate(s), {disk_error_count} disk error(s))"
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_decommission_partial_listing_entry(
|
||||
entries: MetaCacheEntries,
|
||||
resolver: MetadataResolutionParams,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
disk_error_count: usize,
|
||||
pool_index: usize,
|
||||
set_index: usize,
|
||||
) -> Result<MetaCacheEntry> {
|
||||
let candidate_count = entries.as_ref().iter().flatten().count();
|
||||
if let Some(entry) = entries.resolve(resolver) {
|
||||
return Ok(entry);
|
||||
}
|
||||
|
||||
let candidate = entries.as_ref().iter().flatten().map(|entry| entry.name.as_str()).next();
|
||||
Err(decommission_unresolved_listing_error(
|
||||
bucket,
|
||||
prefix,
|
||||
candidate,
|
||||
candidate_count,
|
||||
disk_error_count,
|
||||
pool_index,
|
||||
set_index,
|
||||
))
|
||||
}
|
||||
|
||||
async fn record_decommission_entry_error(
|
||||
entry_error: &Arc<tokio::sync::Mutex<Option<Error>>>,
|
||||
rx: &CancellationToken,
|
||||
err: Error,
|
||||
) {
|
||||
if rx.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut first_err = entry_error.lock().await;
|
||||
if first_err.is_none() && !rx.is_cancelled() {
|
||||
*first_err = Some(err);
|
||||
rx.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_decommission_pool_meta_reload_result(result: Result<()>, stage: &str) -> Result<()> {
|
||||
@@ -1483,6 +1538,7 @@ impl TryFrom<PersistedPoolDecommissionInfo> for PoolDecommissionInfo {
|
||||
terminal_reload_attempt_at: value.terminal_reload_attempt_at,
|
||||
terminal_reload_failures: value.terminal_reload_failures,
|
||||
progress_save_item_baseline: value.items_decommissioned.saturating_add(value.items_decommission_failed),
|
||||
progress_save_retry_after: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1514,6 +1570,7 @@ impl TryFrom<LegacyPoolDecommissionInfo> for PoolDecommissionInfo {
|
||||
terminal_reload_attempt_at: None,
|
||||
terminal_reload_failures: Vec::new(),
|
||||
progress_save_item_baseline: value.items_decommissioned.saturating_add(value.items_decommission_failed),
|
||||
progress_save_retry_after: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1627,6 +1684,82 @@ impl PoolMeta {
|
||||
}
|
||||
}
|
||||
|
||||
fn decommission_progress_checkpoint(
|
||||
&self,
|
||||
idx: usize,
|
||||
duration: Duration,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<Option<DecommissionProgressCheckpoint>> {
|
||||
let pool_count = self.pools.len();
|
||||
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
||||
|
||||
let Some(pool) = self.pools.get(idx) else {
|
||||
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
||||
};
|
||||
let Some(info) = pool.decommission.as_ref() else {
|
||||
return Err(decommission_metadata_not_initialized_error("update decommission metadata timestamp"));
|
||||
};
|
||||
|
||||
if info.progress_save_retry_after.is_some_and(|retry_after| now < retry_after) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let time_threshold_reached = now.unix_timestamp() - pool.last_update.unix_timestamp() >= duration.whole_seconds();
|
||||
let item_threshold_reached = info.items_since_last_progress_save() >= DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD;
|
||||
if !time_threshold_reached && !item_threshold_reached {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(DecommissionProgressCheckpoint {
|
||||
start_time: info.start_time,
|
||||
queued: info.queued,
|
||||
counted_items: info.counted_items(),
|
||||
checkpoint_at: now,
|
||||
}))
|
||||
}
|
||||
|
||||
fn commit_decommission_progress_checkpoint(&mut self, idx: usize, checkpoint: DecommissionProgressCheckpoint) -> bool {
|
||||
let Some(pool) = self.pools.get_mut(idx) else {
|
||||
return false;
|
||||
};
|
||||
let Some(info) = pool.decommission.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if info.start_time != checkpoint.start_time
|
||||
|| info.queued != checkpoint.queued
|
||||
|| !is_decommission_active(info.complete, info.failed, info.canceled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
info.progress_save_item_baseline = info.progress_save_item_baseline.max(checkpoint.counted_items);
|
||||
info.progress_save_retry_after = None;
|
||||
pool.last_update = pool.last_update.max(checkpoint.checkpoint_at);
|
||||
true
|
||||
}
|
||||
|
||||
fn defer_decommission_progress_checkpoint(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
checkpoint: DecommissionProgressCheckpoint,
|
||||
retry_after: OffsetDateTime,
|
||||
) {
|
||||
let Some(pool) = self.pools.get_mut(idx) else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = pool.decommission.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if info.start_time == checkpoint.start_time
|
||||
&& info.queued == checkpoint.queued
|
||||
&& is_decommission_active(info.complete, info.failed, info.canceled)
|
||||
{
|
||||
info.progress_save_retry_after = Some(retry_after);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_config_data(&mut self, data: Vec<u8>) -> Result<()> {
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
@@ -1987,30 +2120,9 @@ impl PoolMeta {
|
||||
}
|
||||
|
||||
pub fn update_after(&mut self, idx: usize, duration: Duration) -> Result<bool> {
|
||||
let pool_count = self.pools.len();
|
||||
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
||||
|
||||
let (last_update, item_threshold_reached) = match self.pools.get(idx) {
|
||||
Some(pool) if let Some(info) = pool.decommission.as_ref() => (
|
||||
pool.last_update,
|
||||
info.items_since_last_progress_save() >= DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
||||
),
|
||||
Some(_) => {
|
||||
return Err(decommission_metadata_not_initialized_error("update decommission metadata timestamp"));
|
||||
}
|
||||
None => return Err(invalid_decommission_pool_index_error(pool_count, idx)),
|
||||
};
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
if now.unix_timestamp() - last_update.unix_timestamp() >= duration.whole_seconds() || item_threshold_reached {
|
||||
let Some(pool) = self.pools.get_mut(idx) else {
|
||||
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
||||
};
|
||||
pool.last_update = now;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
Ok(self
|
||||
.decommission_progress_checkpoint(idx, duration, OffsetDateTime::now_utc())?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
pub fn validate(&self, pools: Vec<Arc<Sets>>) -> Result<bool> {
|
||||
@@ -2151,6 +2263,16 @@ pub struct PoolDecommissionInfo {
|
||||
pub terminal_reload_failures: Vec<String>,
|
||||
#[serde(skip)]
|
||||
pub progress_save_item_baseline: usize,
|
||||
#[serde(skip)]
|
||||
pub progress_save_retry_after: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct DecommissionProgressCheckpoint {
|
||||
start_time: Option<OffsetDateTime>,
|
||||
queued: bool,
|
||||
counted_items: usize,
|
||||
checkpoint_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl PoolDecommissionInfo {
|
||||
@@ -2185,6 +2307,7 @@ impl PoolDecommissionInfo {
|
||||
|
||||
fn mark_progress_saved(&mut self) {
|
||||
self.progress_save_item_baseline = self.counted_items();
|
||||
self.progress_save_retry_after = None;
|
||||
}
|
||||
|
||||
pub fn bucket_push(&mut self, bucket: &DecomBucketInfo) {
|
||||
@@ -2489,6 +2612,40 @@ impl ECStore {
|
||||
snapshot.save(self.pools.clone()).await
|
||||
}
|
||||
|
||||
async fn save_decommission_progress_checkpoint(&self, idx: usize) -> Result<bool> {
|
||||
// Lock order: save gate, then the short pool metadata read/write sections. Peer
|
||||
// reloads are intentionally performed by the caller after both locks are released.
|
||||
let _save_guard = self.pool_meta_save_gate.lock().await;
|
||||
let (snapshot, checkpoint) = {
|
||||
let pool_meta = self.pool_meta.read().await;
|
||||
let Some(checkpoint) = pool_meta.decommission_progress_checkpoint(
|
||||
idx,
|
||||
DECOMMISSION_PROGRESS_SAVE_INTERVAL,
|
||||
OffsetDateTime::now_utc(),
|
||||
)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut snapshot = pool_meta.clone();
|
||||
let Some(pool) = snapshot.pools.get_mut(idx) else {
|
||||
return Err(invalid_decommission_pool_index_error(snapshot.pools.len(), idx));
|
||||
};
|
||||
pool.last_update = checkpoint.checkpoint_at;
|
||||
(snapshot, checkpoint)
|
||||
};
|
||||
|
||||
if let Err(err) = snapshot.save(self.pools.clone()).await {
|
||||
let retry_after = OffsetDateTime::now_utc() + DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF;
|
||||
let mut pool_meta = self.pool_meta.write().await;
|
||||
pool_meta.defer_decommission_progress_checkpoint(idx, checkpoint, retry_after);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let mut pool_meta = self.pool_meta.write().await;
|
||||
Ok(pool_meta.commit_decommission_progress_checkpoint(idx, checkpoint))
|
||||
}
|
||||
|
||||
async fn save_current_pool_meta_for_decommission_start(
|
||||
&self,
|
||||
indices: &[usize],
|
||||
@@ -2871,7 +3028,7 @@ impl ECStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_decommission_entry_progress_stage(
|
||||
async fn track_decommission_entry_progress_stage(
|
||||
&self,
|
||||
idx: usize,
|
||||
bucket: &str,
|
||||
@@ -2882,22 +3039,6 @@ impl ECStore {
|
||||
let mut pool_meta = self.pool_meta.write().await;
|
||||
track_decommission_current_object_stage(&mut pool_meta, idx, bucket, object, stage)
|
||||
.map_err(|err| with_decommission_entry_context(stage, bucket, object, err))?;
|
||||
touch_decommission_progress(&mut pool_meta, idx)
|
||||
.map_err(|err| with_decommission_entry_context(stage, bucket, object, err))?;
|
||||
}
|
||||
|
||||
if let Some(err) = resolve_decommission_progress_save_result(self.save_current_pool_meta().await) {
|
||||
warn!(
|
||||
event = EVENT_DECOMMISSION_ENTRY,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_POOLS,
|
||||
pool_index = idx,
|
||||
bucket = %bucket,
|
||||
object = %object,
|
||||
stage,
|
||||
error = ?err,
|
||||
"Decommission progress stage save failed"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -3165,7 +3306,7 @@ impl ECStore {
|
||||
let bucket_name = bucket.clone();
|
||||
let object_name = rd.object_info.name.clone();
|
||||
|
||||
self.save_decommission_entry_progress_stage(
|
||||
self.track_decommission_entry_progress_stage(
|
||||
idx,
|
||||
bucket_name.as_str(),
|
||||
object_name.as_str(),
|
||||
@@ -3259,7 +3400,7 @@ impl ECStore {
|
||||
}
|
||||
decommission_cancel_signal_result(rx.is_cancelled())?;
|
||||
|
||||
self.save_decommission_entry_progress_stage(
|
||||
self.track_decommission_entry_progress_stage(
|
||||
idx,
|
||||
bucket.as_str(),
|
||||
entry.name.as_str(),
|
||||
@@ -3267,7 +3408,7 @@ impl ECStore {
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.save_decommission_entry_progress_stage(
|
||||
self.track_decommission_entry_progress_stage(
|
||||
idx,
|
||||
bucket.as_str(),
|
||||
entry.name.as_str(),
|
||||
@@ -3334,34 +3475,42 @@ impl ECStore {
|
||||
}
|
||||
};
|
||||
|
||||
self.save_decommission_entry_progress_stage(idx, bucket.as_str(), entry.name.as_str(), DECOMMISSION_STAGE_ENTRY_FINISHED)
|
||||
.await?;
|
||||
self.track_decommission_entry_progress_stage(
|
||||
idx,
|
||||
bucket.as_str(),
|
||||
entry.name.as_str(),
|
||||
DECOMMISSION_STAGE_ENTRY_FINISHED,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if should_save_progress {
|
||||
let save_result = self.save_current_pool_meta().await;
|
||||
if let Some(err) = resolve_decommission_progress_save_result(save_result) {
|
||||
warn!(
|
||||
event = EVENT_DECOMMISSION_ENTRY,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_POOLS,
|
||||
pool_index = idx,
|
||||
bucket = %bucket,
|
||||
object = %entry.name,
|
||||
state = "progress_save_failed",
|
||||
error = %err,
|
||||
"Decommission progress save failed; continuing and will retry at the next checkpoint"
|
||||
);
|
||||
} else {
|
||||
let mut pool_meta = self.pool_meta.write().await;
|
||||
pool_meta.mark_decommission_progress_saved();
|
||||
if let Some(notification_sys) = runtime_sources::notification_sys()
|
||||
&& let Err(err) = resolve_decommission_entry_reload_result(
|
||||
notification_sys.reload_pool_meta().await,
|
||||
bucket.as_str(),
|
||||
entry.name.as_str(),
|
||||
)
|
||||
{
|
||||
warn!("{err}");
|
||||
match self.save_decommission_progress_checkpoint(idx).await {
|
||||
Ok(true) => {
|
||||
if let Some(notification_sys) = runtime_sources::notification_sys()
|
||||
&& let Err(err) = resolve_decommission_entry_reload_result(
|
||||
notification_sys.reload_pool_meta().await,
|
||||
bucket.as_str(),
|
||||
entry.name.as_str(),
|
||||
)
|
||||
{
|
||||
warn!("{err}");
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
if let Some(err) = resolve_decommission_progress_save_result(Err(err)) {
|
||||
warn!(
|
||||
event = EVENT_DECOMMISSION_ENTRY,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_POOLS,
|
||||
pool_index = idx,
|
||||
bucket = %bucket,
|
||||
object = %entry.name,
|
||||
state = "progress_save_failed",
|
||||
error = %err,
|
||||
"Decommission progress save failed; continuing and will retry at the next checkpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3538,6 +3687,7 @@ impl ECStore {
|
||||
let rx_clone = rx.clone();
|
||||
let bi = bi.clone();
|
||||
let set_id = set_idx;
|
||||
let listing_entry_error = entry_error.clone();
|
||||
let worker = tokio::spawn(async move {
|
||||
let _listing_permit = listing_permit;
|
||||
run_decommission_listing_with_retry(
|
||||
@@ -3551,7 +3701,11 @@ impl ECStore {
|
||||
let set = set.clone();
|
||||
let rx = rx_clone.clone();
|
||||
let bucket = bi.clone();
|
||||
async move { set.list_objects_to_decommission(rx, bucket, callback).await }
|
||||
let entry_error = listing_entry_error.clone();
|
||||
async move {
|
||||
set.list_objects_to_decommission(rx, bucket, callback, entry_error.clone(), idx, set_id)
|
||||
.await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -3581,11 +3735,7 @@ impl ECStore {
|
||||
|
||||
wait_decommission_worker_drain(&workers, worker_limit).await?;
|
||||
|
||||
if let Some(err) = listing_worker_error {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Some(err) = entry_error.lock().await.clone() {
|
||||
if let Some(err) = resolve_decommission_listing_error(listing_worker_error, entry_error.lock().await.clone()) {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
@@ -4191,7 +4341,7 @@ impl ECStore {
|
||||
let buckets = self.get_buckets_to_decommission().await?;
|
||||
let pool = self.pools[idx].clone();
|
||||
|
||||
for set in &pool.disk_set {
|
||||
for (set_index, set) in pool.disk_set.iter().enumerate() {
|
||||
for bucket_info in &buckets {
|
||||
let mut lifecycle_config = None;
|
||||
let mut object_lock_config = None;
|
||||
@@ -4286,7 +4436,7 @@ impl ECStore {
|
||||
});
|
||||
|
||||
let list_result = set
|
||||
.list_objects_to_decommission(callback_rx, bucket_info.clone(), callback)
|
||||
.list_objects_to_decommission(callback_rx, bucket_info.clone(), callback, entry_error.clone(), idx, set_index)
|
||||
.await;
|
||||
let entry_error = entry_error.lock().await.clone();
|
||||
resolve_decommission_check_after_list_result(list_result, entry_error)?;
|
||||
@@ -5021,12 +5171,15 @@ mod tests {
|
||||
pub type ListCallback = Arc<dyn Fn(MetaCacheEntry) -> BoxFuture<'static, ()> + Send + Sync + 'static>;
|
||||
|
||||
impl SetDisks {
|
||||
#[tracing::instrument(skip(self, rx, cb_func))]
|
||||
#[tracing::instrument(skip(self, rx, cb_func, entry_error))]
|
||||
async fn list_objects_to_decommission(
|
||||
self: &Arc<Self>,
|
||||
rx: CancellationToken,
|
||||
bucket_info: DecomBucketInfo,
|
||||
cb_func: ListCallback,
|
||||
entry_error: Arc<tokio::sync::Mutex<Option<Error>>>,
|
||||
pool_index: usize,
|
||||
set_index: usize,
|
||||
) -> Result<()> {
|
||||
let (disks, _) = self.get_online_disks_with_healing(false).await;
|
||||
ensure_decommission_listing_disks_available(!disks.is_empty(), &bucket_info.name)?;
|
||||
@@ -5041,6 +5194,12 @@ impl SetDisks {
|
||||
};
|
||||
|
||||
let cb1 = cb_func.clone();
|
||||
let unresolved_error = entry_error.clone();
|
||||
let unresolved_rx = rx.clone();
|
||||
let unresolved_bucket = bucket_info.name.clone();
|
||||
let unresolved_prefix = bucket_info.prefix.clone();
|
||||
let unresolved_pool_index = pool_index;
|
||||
let unresolved_set_index = set_index;
|
||||
|
||||
list_path_raw(
|
||||
rx,
|
||||
@@ -5053,20 +5212,51 @@ impl SetDisks {
|
||||
skip_walkdir_total_timeout: true,
|
||||
walkdir_stall_timeout: Some(DECOMMISSION_BACKGROUND_WALKDIR_STALL_TIMEOUT),
|
||||
agreed: Some(Box::new(move |entry: MetaCacheEntry| Box::pin(cb1(entry)))),
|
||||
partial: Some(Box::new(move |entries: MetaCacheEntries, _: &[Option<DiskError>]| {
|
||||
partial: Some(Box::new(move |entries: MetaCacheEntries, errs: &[Option<DiskError>]| {
|
||||
let resolver = resolver.clone();
|
||||
let cb_func = cb_func.clone();
|
||||
match entries.resolve(resolver) {
|
||||
Some(entry) => {
|
||||
let bucket = unresolved_bucket.clone();
|
||||
let prefix = unresolved_prefix.clone();
|
||||
let unresolved_error = unresolved_error.clone();
|
||||
let unresolved_rx = unresolved_rx.clone();
|
||||
let pool_index = unresolved_pool_index;
|
||||
let set_index = unresolved_set_index;
|
||||
let disk_error_count = errs.iter().flatten().count();
|
||||
if unresolved_rx.is_cancelled() {
|
||||
return Box::pin(async {});
|
||||
}
|
||||
|
||||
match resolve_decommission_partial_listing_entry(
|
||||
entries,
|
||||
resolver,
|
||||
&bucket,
|
||||
&prefix,
|
||||
disk_error_count,
|
||||
pool_index,
|
||||
set_index,
|
||||
) {
|
||||
Ok(entry) => {
|
||||
warn!("decommission_pool: list_objects_to_decommission get {}", &entry.name);
|
||||
Box::pin(async move {
|
||||
cb_func(entry).await;
|
||||
})
|
||||
}
|
||||
None => {
|
||||
warn!("decommission_pool: list_objects_to_decommission get none");
|
||||
Box::pin(async {})
|
||||
}
|
||||
Err(err) => Box::pin(async move {
|
||||
if unresolved_rx.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
warn!(
|
||||
event = EVENT_DECOMMISSION_BUCKET,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_POOLS,
|
||||
bucket = %bucket,
|
||||
prefix = %prefix,
|
||||
state = "unresolved_entry",
|
||||
error = %err,
|
||||
"Decommission listing failed closed on unresolved metadata"
|
||||
);
|
||||
record_decommission_entry_error(&unresolved_error, &unresolved_rx, err).await;
|
||||
}),
|
||||
}
|
||||
})),
|
||||
..Default::default()
|
||||
@@ -5074,6 +5264,10 @@ impl SetDisks {
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(err) = entry_error.lock().await.clone() {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5264,11 +5458,11 @@ pub(crate) fn fallback_free_capacity_dedup(disks: &[rustfs_madmin::Disk]) -> usi
|
||||
#[cfg(test)]
|
||||
mod pools_tests {
|
||||
use super::{
|
||||
DECOMMISSION_PROGRESS_SAVE_INTERVAL, DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD, DecomBucketInfo,
|
||||
DecommissionStartPoolState, DecommissionTerminalState, ListCallback, PoolDecommissionInfo, PoolMeta, PoolSpaceInfo,
|
||||
PoolStatus, apply_decommission_status_space_info, bind_decommission_cancelers, bind_missing_decommission_cancelers,
|
||||
cancel_decommission_canceler, classify_decommission_terminal_state, count_decommission_item,
|
||||
decommission_cancel_signal_result, decommission_item_size, decommission_meta_bucket_options,
|
||||
DECOMMISSION_PROGRESS_SAVE_INTERVAL, DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD, DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF,
|
||||
DecomBucketInfo, DecommissionStartPoolState, DecommissionTerminalState, ListCallback, PoolDecommissionInfo, PoolMeta,
|
||||
PoolSpaceInfo, PoolStatus, apply_decommission_status_space_info, bind_decommission_cancelers,
|
||||
bind_missing_decommission_cancelers, cancel_decommission_canceler, classify_decommission_terminal_state,
|
||||
count_decommission_item, decommission_cancel_signal_result, decommission_item_size, decommission_meta_bucket_options,
|
||||
decommission_start_pool_state, dedup_indices, default_decommission_bucket_concurrency,
|
||||
ensure_decommission_cancel_allowed, ensure_decommission_clear_allowed, ensure_decommission_listing_disks_available,
|
||||
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
|
||||
@@ -5279,11 +5473,12 @@ mod pools_tests {
|
||||
has_active_decommission_canceler, is_decommission_active, is_decommission_cancel_requested,
|
||||
load_decommission_entry_versions, local_decommission_queue_prefix, mark_decommission_bucket_done,
|
||||
merge_pool_status_refresh, missing_decommission_worker_prefix, observe_decommission_terminal_reload_result,
|
||||
pool_meta_has_active_decommission, require_decommission_store, resolve_decommission_bucket_done_save_result,
|
||||
resolve_decommission_bucket_state, resolve_decommission_check_after_list_result,
|
||||
resolve_decommission_entry_cleanup_delete_result, resolve_decommission_entry_exact_versions,
|
||||
resolve_decommission_entry_reload_result, resolve_decommission_listing_worker_result,
|
||||
resolve_decommission_optional_bucket_config_result, resolve_decommission_pool_meta_reload_result,
|
||||
pool_meta_has_active_decommission, record_decommission_entry_error, require_decommission_store,
|
||||
resolve_decommission_bucket_done_save_result, resolve_decommission_bucket_state,
|
||||
resolve_decommission_check_after_list_result, resolve_decommission_entry_cleanup_delete_result,
|
||||
resolve_decommission_entry_exact_versions, resolve_decommission_entry_reload_result, resolve_decommission_listing_error,
|
||||
resolve_decommission_listing_worker_result, resolve_decommission_optional_bucket_config_result,
|
||||
resolve_decommission_partial_listing_entry, resolve_decommission_pool_meta_reload_result,
|
||||
resolve_decommission_preflight_heal_result, resolve_decommission_progress_save_result,
|
||||
resolve_decommission_spawn_failure_result, resolve_decommission_terminal_mark_after_error_result,
|
||||
resolve_decommission_terminal_mark_result, resolve_decommission_update_after_result,
|
||||
@@ -5293,16 +5488,17 @@ mod pools_tests {
|
||||
should_preserve_decommission_canceled_state, should_reject_decommission_cancel_as_terminal,
|
||||
should_retry_decommission_cancel_reload, should_retry_decommission_listing, should_skip_canceled_decommission_routine,
|
||||
split_decommission_buckets, take_and_cancel_decommission_canceler, take_decommission_canceler,
|
||||
touch_decommission_progress, track_decommission_current_object, track_decommission_current_object_stage,
|
||||
validate_start_decommission_request, wait_decommission_listing_retry, wait_decommission_worker_drain,
|
||||
with_decommission_entry_context,
|
||||
track_decommission_current_object, track_decommission_current_object_stage, validate_start_decommission_request,
|
||||
wait_decommission_listing_retry, wait_decommission_worker_drain, with_decommission_entry_context,
|
||||
};
|
||||
use crate::data_movement;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::error::{Error, StorageError};
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::services::rebalance::{RebalStatus, RebalanceInfo, RebalanceMeta, RebalanceStats};
|
||||
use rustfs_filemeta::{FileInfo, FileInfoVersions, MetaCacheEntry, ObjectPartInfo};
|
||||
use rustfs_filemeta::{
|
||||
FileInfo, FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams, ObjectPartInfo,
|
||||
};
|
||||
use rustfs_rio::Index;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
@@ -6321,6 +6517,65 @@ mod pools_tests {
|
||||
assert!(matches!(err, Error::SlowDown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_decommission_partial_listing_entry_rejects_unresolved_metadata() {
|
||||
let err = resolve_decommission_partial_listing_entry(
|
||||
MetaCacheEntries(vec![None]),
|
||||
MetadataResolutionParams {
|
||||
dir_quorum: 2,
|
||||
obj_quorum: 2,
|
||||
bucket: "bucket-a".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
"bucket-a",
|
||||
"prefix/",
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
)
|
||||
.expect_err("unresolved partial listing must fail closed");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("decommission listing could not resolve metadata"));
|
||||
assert!(message.contains("bucket-a/prefix/"));
|
||||
assert!(message.contains("pool 2 set 3"));
|
||||
assert!(message.contains("1 disk error(s)"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_decommission_entry_error_cancels_listing_and_preserves_first_error() {
|
||||
let entry_error = Arc::new(tokio::sync::Mutex::new(None));
|
||||
let rx = CancellationToken::new();
|
||||
|
||||
record_decommission_entry_error(&entry_error, &rx, Error::SlowDown).await;
|
||||
record_decommission_entry_error(&entry_error, &rx, Error::OperationCanceled).await;
|
||||
|
||||
assert!(rx.is_cancelled());
|
||||
assert!(matches!(*entry_error.lock().await, Some(Error::SlowDown)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_decommission_entry_error_ignores_already_canceled_listing() {
|
||||
let entry_error = Arc::new(tokio::sync::Mutex::new(None));
|
||||
let rx = CancellationToken::new();
|
||||
rx.cancel();
|
||||
|
||||
record_decommission_entry_error(&entry_error, &rx, Error::SlowDown).await;
|
||||
|
||||
assert!(entry_error.lock().await.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_decommission_listing_error_preserves_real_listing_failure() {
|
||||
let err = resolve_decommission_listing_error(Some(Error::SlowDown), Some(Error::OperationCanceled))
|
||||
.expect("listing failure should be returned");
|
||||
assert!(matches!(err, Error::SlowDown));
|
||||
|
||||
let err = resolve_decommission_listing_error(Some(Error::OperationCanceled), Some(Error::SlowDown))
|
||||
.expect("entry failure should be returned");
|
||||
assert!(matches!(err, Error::SlowDown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_decommission_check_after_list_result_returns_list_result_without_entry_error() {
|
||||
let err = resolve_decommission_check_after_list_result(Err(Error::OperationCanceled), None)
|
||||
@@ -6538,7 +6793,7 @@ mod pools_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_touch_decommission_progress_updates_last_update_and_save_baseline() {
|
||||
fn test_track_decommission_stage_does_not_advance_checkpoint_state() {
|
||||
let mut meta = PoolMeta {
|
||||
pools: vec![PoolStatus {
|
||||
id: 0,
|
||||
@@ -6553,11 +6808,13 @@ mod pools_tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
touch_decommission_progress(&mut meta, 0).expect("valid decommission progress should be touched");
|
||||
track_decommission_current_object_stage(&mut meta, 0, "bucket", "object", "migrate_object")
|
||||
.expect("valid decommission progress should be tracked");
|
||||
|
||||
assert!(meta.pools[0].last_update > OffsetDateTime::UNIX_EPOCH);
|
||||
assert_eq!(meta.pools[0].last_update, OffsetDateTime::UNIX_EPOCH);
|
||||
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
||||
assert_eq!(info.items_since_last_progress_save(), 0);
|
||||
assert_eq!(info.items_since_last_progress_save(), 5);
|
||||
assert_eq!(info.stage, "migrate_object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6632,6 +6889,134 @@ mod pools_tests {
|
||||
assert_eq!(info.items_since_last_progress_save(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pool_meta_update_after_does_not_advance_last_update_before_save() {
|
||||
let last_update = OffsetDateTime::UNIX_EPOCH;
|
||||
let mut meta = PoolMeta {
|
||||
pools: vec![PoolStatus {
|
||||
id: 0,
|
||||
cmd_line: "pool-0".to_string(),
|
||||
last_update,
|
||||
decommission: Some(PoolDecommissionInfo {
|
||||
start_time: Some(last_update),
|
||||
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
||||
..Default::default()
|
||||
}),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
meta.update_after(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL)
|
||||
.expect("item threshold should request a checkpoint")
|
||||
);
|
||||
assert_eq!(meta.pools[0].last_update, last_update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decommission_progress_checkpoint_commits_exact_snapshot_watermark() {
|
||||
let start_time = OffsetDateTime::UNIX_EPOCH;
|
||||
let checkpoint_at = start_time + Duration::seconds(30);
|
||||
let mut meta = PoolMeta {
|
||||
pools: vec![PoolStatus {
|
||||
id: 0,
|
||||
cmd_line: "pool-0".to_string(),
|
||||
last_update: start_time,
|
||||
decommission: Some(PoolDecommissionInfo {
|
||||
start_time: Some(start_time),
|
||||
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
||||
..Default::default()
|
||||
}),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let checkpoint = meta
|
||||
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at)
|
||||
.expect("valid decommission state should produce a checkpoint")
|
||||
.expect("item threshold should produce a checkpoint");
|
||||
meta.count_item(0, 1, false);
|
||||
|
||||
assert!(meta.commit_decommission_progress_checkpoint(0, checkpoint));
|
||||
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
||||
assert_eq!(info.progress_save_item_baseline, checkpoint.counted_items);
|
||||
assert_eq!(info.items_since_last_progress_save(), 1);
|
||||
assert_eq!(meta.pools[0].last_update, checkpoint_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decommission_progress_checkpoint_backoff_does_not_advance_baseline() {
|
||||
let start_time = OffsetDateTime::UNIX_EPOCH;
|
||||
let checkpoint_at = start_time + Duration::seconds(30);
|
||||
let retry_after = checkpoint_at + DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF;
|
||||
let mut meta = PoolMeta {
|
||||
pools: vec![PoolStatus {
|
||||
id: 0,
|
||||
cmd_line: "pool-0".to_string(),
|
||||
last_update: start_time,
|
||||
decommission: Some(PoolDecommissionInfo {
|
||||
start_time: Some(start_time),
|
||||
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
||||
..Default::default()
|
||||
}),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let checkpoint = meta
|
||||
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at)
|
||||
.expect("valid decommission state should produce a checkpoint")
|
||||
.expect("item threshold should produce a checkpoint");
|
||||
meta.defer_decommission_progress_checkpoint(0, checkpoint, retry_after);
|
||||
|
||||
assert!(
|
||||
meta.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at)
|
||||
.expect("retry backoff check should succeed")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(meta.pools[0].last_update, start_time);
|
||||
assert_eq!(
|
||||
meta.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.expect("decommission info should exist")
|
||||
.progress_save_item_baseline,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decommission_progress_checkpoint_count_scales_with_threshold() {
|
||||
let start_time = OffsetDateTime::UNIX_EPOCH;
|
||||
let checkpoint_at = start_time;
|
||||
let mut meta = PoolMeta {
|
||||
pools: vec![PoolStatus {
|
||||
id: 0,
|
||||
cmd_line: "pool-0".to_string(),
|
||||
last_update: start_time,
|
||||
decommission: Some(PoolDecommissionInfo {
|
||||
start_time: Some(start_time),
|
||||
..Default::default()
|
||||
}),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let mut checkpoint_count = 0;
|
||||
|
||||
for _ in 0..(DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD * 10) {
|
||||
meta.count_item(0, 1, false);
|
||||
if let Some(checkpoint) = meta
|
||||
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at)
|
||||
.expect("valid decommission state should produce a checkpoint")
|
||||
{
|
||||
checkpoint_count += 1;
|
||||
assert!(meta.commit_decommission_progress_checkpoint(0, checkpoint));
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(checkpoint_count, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_decommission_not_rebalancing_rejects_running_rebalance() {
|
||||
let err = ensure_decommission_not_rebalancing(true).expect_err("rebalance running should be rejected");
|
||||
|
||||
@@ -315,7 +315,7 @@ pub async fn fsync_dir(dir: impl AsRef<Path>) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || fsync_dir_std(dir)).await?
|
||||
fsync_spawn_blocking(move || fsync_dir_std(dir)).await?
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
@@ -683,7 +683,7 @@ async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup) -> io::Result<()> {
|
||||
#[cfg(test)]
|
||||
let dir = group.dir.clone();
|
||||
let dir_file = group.dir_file.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
fsync_spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Some(kind) = fsync_dir_recorder::take_grouped_failure(&dir) {
|
||||
@@ -1080,6 +1080,44 @@ const TEST_GLOBAL_FILE_SYNCS: usize = 64;
|
||||
|
||||
static FILE_SYNC_PERMITS: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(global_file_sync_limit()));
|
||||
static DISK_FILE_SYNC_LIMITERS: LazyLock<Mutex<HashMap<PathBuf, Weak<Semaphore>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Dedicated tokio runtime for fsync/fdatasync blocking operations. When
|
||||
/// configured with >1 threads, isolates device-bound fsync from the main
|
||||
/// blocking pool so reads (pread/stat/open) are not starved. `None` means
|
||||
/// fall back to the main runtime (zero behavior change).
|
||||
static FSYNC_RUNTIME: LazyLock<Option<tokio::runtime::Runtime>> = LazyLock::new(|| {
|
||||
let threads =
|
||||
rustfs_utils::get_env_usize(rustfs_config::ENV_FSYNC_BLOCKING_THREADS, rustfs_config::DEFAULT_FSYNC_BLOCKING_THREADS);
|
||||
if threads <= 1 {
|
||||
return None;
|
||||
}
|
||||
let mut builder = tokio::runtime::Builder::new_multi_thread();
|
||||
builder
|
||||
.worker_threads(num_cpus::get().min(8))
|
||||
.max_blocking_threads(threads)
|
||||
.thread_name("rustfs-fsync")
|
||||
.thread_stack_size(512 * 1024)
|
||||
.enable_all();
|
||||
match builder.build() {
|
||||
Ok(rt) => {
|
||||
tracing::info!(threads, "fsync dedicated blocking pool enabled");
|
||||
Some(rt)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "failed to build fsync runtime, falling back to main pool");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// Spawn a blocking task on the fsync-dedicated runtime if configured,
|
||||
/// otherwise fall back to the main tokio blocking pool.
|
||||
fn fsync_spawn_blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> tokio::task::JoinHandle<T> {
|
||||
match FSYNC_RUNTIME.as_ref() {
|
||||
Some(rt) => rt.spawn_blocking(f),
|
||||
None => tokio::task::spawn_blocking(f),
|
||||
}
|
||||
}
|
||||
static DISK_VOLUME_MUTATION_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Weak<RwLock<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
type NamespaceMutationLock = AsyncMutex<()>;
|
||||
@@ -1217,7 +1255,7 @@ where
|
||||
F: FnOnce() -> io::Result<T> + Send + 'static,
|
||||
{
|
||||
let (disk_permit, global_permit) = acquire_file_sync_permits(disk_permits).await?;
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let result = fsync_spawn_blocking(move || {
|
||||
let _disk_permit = disk_permit;
|
||||
work()
|
||||
})
|
||||
@@ -2146,7 +2184,7 @@ async fn run_blocking_namespace_file_sync_operation_with_global<T: Send + 'stati
|
||||
wait_started,
|
||||
);
|
||||
let disk_permit = admission.disk_permit.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let result = fsync_spawn_blocking(move || {
|
||||
let _lease = lease;
|
||||
let _disk_permit = disk_permit;
|
||||
operation()
|
||||
|
||||
@@ -2106,6 +2106,9 @@ pub enum ChannelClass {
|
||||
Bulk,
|
||||
}
|
||||
|
||||
// Keep multiplexed unary RPCs below h2's per-connection small-frame budget.
|
||||
const INTERNODE_RPC_CONCURRENCY_LIMIT: usize = 64;
|
||||
|
||||
/// Whether control/bulk channel isolation is enabled (env-gated, default off for safe rollout).
|
||||
fn channel_isolation_enabled() -> bool {
|
||||
rustfs_utils::get_env_bool(
|
||||
@@ -2188,6 +2191,7 @@ async fn build_channel(dial_addr: &str, cache_key: &str) -> Result<Channel, Box<
|
||||
let mut connector = Endpoint::from_shared(dial_addr.to_string())?
|
||||
// Fast connection timeout for dead peer detection
|
||||
.connect_timeout(connect_timeout)
|
||||
.concurrency_limit(INTERNODE_RPC_CONCURRENCY_LIMIT)
|
||||
// TCP-level keepalive - OS will probe connection
|
||||
.tcp_keepalive(Some(tcp_keepalive))
|
||||
// Disable Nagle so latency-sensitive control-plane RPCs (locks/health) are not batched
|
||||
|
||||
@@ -513,13 +513,15 @@ fn sr_bucket_meta_item(bucket: String, item_type: &str) -> SRBucketMeta {
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_bucket_metadata_reload(
|
||||
async fn notify_bucket_metadata_reload(
|
||||
bucket: String,
|
||||
operation: &'static str,
|
||||
request_context: Option<request_context::RequestContext>,
|
||||
scanner_maintenance_change: bool,
|
||||
) {
|
||||
record_local_scanner_maintenance_reload(&bucket, scanner_maintenance_change);
|
||||
// Keep reload detached across request cancellation, but wait before a healthy peer can serve the previous config.
|
||||
let (completed_tx, completed_rx) = tokio::sync::oneshot::channel();
|
||||
spawn_background_with_context(request_context, async move {
|
||||
if let Some(notification_sys) = current_notification_system() {
|
||||
let result = if scanner_maintenance_change {
|
||||
@@ -531,7 +533,9 @@ fn notify_bucket_metadata_reload(
|
||||
warn!(bucket = %bucket, error = %err, "failed to notify peers after {operation}");
|
||||
}
|
||||
}
|
||||
let _ = completed_tx.send(());
|
||||
});
|
||||
let _ = completed_rx.await;
|
||||
}
|
||||
|
||||
fn record_local_scanner_maintenance_reload(bucket: &str, scanner_maintenance_change: bool) {
|
||||
@@ -1476,7 +1480,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket encryption", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket encryption", request_context, false).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "sse-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
@@ -1508,7 +1512,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket cors", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket cors", request_context, false).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "cors-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
@@ -1540,7 +1544,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket lifecycle", request_context, true);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket lifecycle", request_context, true).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "lc-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
@@ -1572,7 +1576,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket policy", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket policy", request_context, false).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "policy");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
@@ -1630,7 +1634,7 @@ impl DefaultBucketUsecase {
|
||||
}
|
||||
drop(targets_guard);
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket replication", request_context, true);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket replication", request_context, true).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "replication-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
@@ -1655,7 +1659,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket tagging", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket tagging", request_context, false).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "tags");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
@@ -1688,7 +1692,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete public access block", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete public access block", request_context, false).await;
|
||||
|
||||
Ok(S3Response::with_status(DeletePublicAccessBlockOutput::default(), StatusCode::NO_CONTENT))
|
||||
}
|
||||
@@ -2143,7 +2147,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket encryption", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket encryption", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "sse-config");
|
||||
item.sse_config = Some(
|
||||
@@ -2222,7 +2226,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket lifecycle", request_context, true);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket lifecycle", request_context, true).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "lc-config");
|
||||
item.expiry_lc_config =
|
||||
@@ -2307,7 +2311,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket notification", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket notification", request_context, false).await;
|
||||
|
||||
let region = resolve_notification_region(self.global_region(), request_region);
|
||||
let notify = current_notify_interface_for_context(self.context.as_deref());
|
||||
@@ -2412,7 +2416,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket policy", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket policy", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "policy");
|
||||
item.policy = Some(serde_json::from_str(&policy).map_err(|e| s3_error!(InvalidArgument, "parse policy failed {:?}", e))?);
|
||||
@@ -2447,7 +2451,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket cors", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket cors", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "cors-config");
|
||||
item.cors =
|
||||
@@ -2491,7 +2495,7 @@ impl DefaultBucketUsecase {
|
||||
.map_err(ApiError::from)?;
|
||||
drop(targets_guard);
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket replication", request_context, true);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket replication", request_context, true).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "replication-config");
|
||||
item.replication_config = Some(
|
||||
@@ -2531,7 +2535,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put public access block", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put public access block", request_context, false).await;
|
||||
|
||||
Ok(S3Response::new(PutPublicAccessBlockOutput::default()))
|
||||
}
|
||||
@@ -2560,7 +2564,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket tagging", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket tagging", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "tags");
|
||||
item.tags = Some(serialize_config(&tagging).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
|
||||
@@ -2593,7 +2597,7 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket versioning", request_context, false);
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket versioning", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "version-config");
|
||||
item.versioning = Some(
|
||||
@@ -3044,7 +3048,7 @@ mod tests {
|
||||
"{method} should identify the bucket metadata operation in reload logs"
|
||||
);
|
||||
let expected_reload = format!(
|
||||
"notify_bucket_metadata_reload(bucket.clone(), \"{operation}\", request_context, {scanner_maintenance_change});"
|
||||
"notify_bucket_metadata_reload(bucket.clone(), \"{operation}\", request_context, {scanner_maintenance_change}).await;"
|
||||
);
|
||||
assert!(
|
||||
body.contains(&expected_reload),
|
||||
|
||||
@@ -206,6 +206,13 @@ def check_runner_selection(root: Path) -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def check_s3_tests_runner(root: Path) -> list[str]:
|
||||
runner = (root / "scripts/s3-tests/run.sh").read_text()
|
||||
if "--showlocals" in runner:
|
||||
return ["scripts/s3-tests/run.sh: pytest failure diagnostics must not dump local values"]
|
||||
return []
|
||||
|
||||
|
||||
def profile_selection(root: Path, profile: str) -> str:
|
||||
if not re.fullmatch(r"e2e-[a-z0-9-]+", profile):
|
||||
raise ValueError(f"invalid e2e profile name: {profile}")
|
||||
@@ -272,6 +279,7 @@ def validate(root: Path) -> list[str]:
|
||||
errors.extend(check_e2e_modules(root))
|
||||
errors.extend(check_fuzz_targets(root))
|
||||
errors.extend(check_runner_selection(root))
|
||||
errors.extend(check_s3_tests_runner(root))
|
||||
errors.extend(check_profile_definitions(root))
|
||||
return errors
|
||||
|
||||
@@ -341,6 +349,23 @@ class SelfTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(len(check_fuzz_targets(root)), 1)
|
||||
|
||||
def test_s3_runner_rejects_unbounded_failure_locals(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
runner = root / "scripts/s3-tests/run.sh"
|
||||
runner.parent.mkdir(parents=True)
|
||||
runner.write_text("tox -- -vv -ra --tb=long\n")
|
||||
self.assertEqual(check_s3_tests_runner(root), [])
|
||||
runner.write_text("tox -- -vv -ra --showlocals --tb=long\n")
|
||||
self.assertEqual(len(check_s3_tests_runner(root)), 1)
|
||||
with (
|
||||
mock.patch(__name__ + ".check_e2e_modules", return_value=[]),
|
||||
mock.patch(__name__ + ".check_fuzz_targets", return_value=[]),
|
||||
mock.patch(__name__ + ".check_runner_selection", return_value=[]),
|
||||
mock.patch(__name__ + ".check_profile_definitions", return_value=[]),
|
||||
):
|
||||
self.assertEqual(len(validate(root)), 1)
|
||||
|
||||
def test_profile_listing_enforces_selection(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -411,7 +436,7 @@ def main() -> int:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print("OK: e2e modules, runner selection, fuzz matrices, and profile guards are wired")
|
||||
print("OK: e2e modules, runner selection, fuzz matrices, profiles, and bounded diagnostics are wired")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1028,10 +1028,11 @@ else
|
||||
fi
|
||||
|
||||
# Run tests from s3tests/functional
|
||||
# Failure locals can contain multi-MiB request bodies; keep tracebacks without expanding local values.
|
||||
set +e
|
||||
S3TEST_CONF="${CONF_OUTPUT_PATH}" \
|
||||
tox -- \
|
||||
-vv -ra --showlocals --tb=long \
|
||||
-vv -ra --tb=long \
|
||||
--maxfail="${MAXFAIL}" \
|
||||
--timeout="${TEST_TIMEOUT}" \
|
||||
--junitxml="${ARTIFACTS_DIR}/junit.xml" \
|
||||
|
||||
Reference in New Issue
Block a user