Compare commits

..

1 Commits

Author SHA1 Message Date
overtrue 1ac09383ad fix(ecstore): persist decommission cancel before signaling 2026-08-23 07:53:52 +08:00
16 changed files with 824 additions and 526 deletions
+1 -2
View File
@@ -30,8 +30,7 @@ make build-docker BUILD_OS=ubuntu22.04
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- CI gates: `.github/workflows/ci.yml` (source of truth; never copy its steps into docs)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
-2
View File
@@ -70,8 +70,6 @@ make pre-pr
> For the full test-layer taxonomy (unit / ecstore black-box / e2e / s3s-e2e / S3 compatibility / chaos / fuzz / bench), each layer's entry command, the naming conventions the migration gate depends on, and the serial/nextest rules, see [docs/testing/README.md](docs/testing/README.md).
> For the event, timeout, required-status, and local reproduction matrix, see [docs/testing/ci-gates.md](docs/testing/ci-gates.md).
### 🔒 Automated Pre-commit Hooks
#### What `make pre-commit` and `make pre-pr` actually run
Generated
+27 -22
View File
@@ -1858,9 +1858,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.4"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2522,6 +2522,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "cty"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -5982,6 +5988,15 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11#6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11"
dependencies = [
"cc",
"cty",
]
[[package]]
name = "libredox"
version = "0.1.20"
@@ -6382,6 +6397,14 @@ dependencies = [
"synstructure 0.13.2",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11#6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -9139,11 +9162,13 @@ dependencies = [
"insta",
"jiff",
"libc",
"libmimalloc-sys",
"libsystemd",
"matchit 0.9.2",
"md-5 0.11.0",
"metrics",
"metrics-util",
"mimalloc",
"mime_guess",
"opentelemetry",
"opentelemetry_sdk",
@@ -9179,8 +9204,6 @@ dependencies = [
"rustfs-lock",
"rustfs-log-analyzer",
"rustfs-madmin",
"rustfs-mimalloc",
"rustfs-mimalloc-sys",
"rustfs-notify",
"rustfs-object-capacity",
"rustfs-object-data-cache",
@@ -9852,24 +9875,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "rustfs-mimalloc"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a406f4aa07084301d485beec873af6dccc8e3f8762da244743df92038b1db1a6"
dependencies = [
"rustfs-mimalloc-sys",
]
[[package]]
name = "rustfs-mimalloc-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3051b819175f58445d4c369a72f0ab88149f3885ba8bea2aff3be01f53fe7cd"
dependencies = [
"cc",
]
[[package]]
name = "rustfs-notify"
version = "1.0.0-rc.3"
+2 -2
View File
@@ -350,8 +350,8 @@ russh-sftp = "2.4.0"
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.0" }
rustfs-mimalloc-sys = { version = "0.5.0" }
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11" }
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11", features = ["extended"] }
hotpath = { version = "0.23.3", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.48" }
@@ -32,10 +32,6 @@ pub struct Credentials {
pub access_key: String,
#[serde(rename = "secretKey")]
pub secret_key: String,
// The aliases accept madmin's JSON tags (MinIO-written bucket-targets
// metadata and mc request bodies) without changing the snake_case
// persisted/peer wire format this struct serializes to.
#[serde(alias = "sessionToken")]
pub session_token: Option<String>,
pub expiration: Option<Timestamp>,
}
@@ -206,14 +202,12 @@ pub struct BucketTarget {
#[serde(default)]
pub region: String,
// madmin-go v3.0.109 tags this `bandwidthlimit`; `bandwidth` is a legacy
// alias kept for inputs written before the madmin tag was verified.
#[serde(alias = "bandwidthlimit", alias = "bandwidth", default)]
#[serde(alias = "bandwidth", default)]
pub bandwidth_limit: i64,
#[serde(rename = "replicationSync", default)]
pub replication_sync: bool,
#[serde(alias = "storageclass", default)]
#[serde(default)]
pub storage_class: String,
#[serde(rename = "skipTlsVerify", default)]
pub skip_tls_verify: bool,
@@ -226,7 +220,7 @@ pub struct BucketTarget {
#[serde(rename = "resetBeforeDate", with = "time::serde::rfc3339::option", default)]
pub reset_before_date: Option<OffsetDateTime>,
#[serde(alias = "resetID", default)]
#[serde(default)]
pub reset_id: String,
#[serde(rename = "totalDowntime", with = "duration_seconds", default)]
pub total_downtime: Duration,
@@ -239,7 +233,7 @@ pub struct BucketTarget {
#[serde(default)]
pub latency: LatencyStat,
#[serde(alias = "deploymentID", default)]
#[serde(default)]
pub deployment_id: String,
#[serde(default)]
@@ -537,85 +531,6 @@ mod tests {
assert_eq!(value["totalDowntime"], 90);
}
#[test]
fn bucket_target_persisted_wire_keys_stay_snake_case() {
// bucket-targets.json (persisted via `serde_json::to_vec(&BucketTargets)`
// in the admin set/remove handlers) and the msgpack struct-map form
// (`BucketTargets::marshal_msg`) both come straight from this struct's
// serde field names. madmin naming is applied only in the admin
// response layer (`remote_target_admin_json`); renaming here would
// silently break every existing deployment's persisted metadata.
let targets = BucketTargets {
targets: vec![BucketTarget {
credentials: Some(Credentials {
access_key: "ak".to_string(),
secret_key: "sk".to_string(),
session_token: Some("token".to_string()),
expiration: None,
}),
bandwidth_limit: 5,
storage_class: "STANDARD".to_string(),
reset_id: "reset-1".to_string(),
deployment_id: "deploy-1".to_string(),
..Default::default()
}],
};
let json = serde_json::to_value(&targets).expect("targets should serialize to JSON");
let msgpack: serde_json::Value =
rmp_serde::from_slice(&targets.marshal_msg().expect("targets should marshal to msgpack"))
.expect("msgpack struct map should decode into a JSON value");
for (wire, entry) in [("JSON", &json["targets"][0]), ("msgpack", &msgpack["targets"][0])] {
assert_eq!(entry["bandwidth_limit"], 5, "{wire} key `bandwidth_limit` must stay");
assert_eq!(entry["storage_class"], "STANDARD", "{wire} key `storage_class` must stay");
assert_eq!(entry["reset_id"], "reset-1", "{wire} key `reset_id` must stay");
assert_eq!(entry["deployment_id"], "deploy-1", "{wire} key `deployment_id` must stay");
assert_eq!(entry["credentials"]["session_token"], "token", "{wire} key `session_token` must stay");
}
}
#[test]
fn minio_written_bucket_targets_json_populates_madmin_named_fields() {
// A MinIO-written bucket-targets.json carries madmin's JSON tags
// (`bandwidthlimit`, `storageclass`, `resetID`, `deploymentID`,
// `credentials.sessionToken` — madmin-go v3.0.109 bucket-targets.go).
// On migration these must land in the matching fields instead of
// silently defaulting (backlog#1951).
let targets: BucketTargets = serde_json::from_value(serde_json::json!({
"targets": [{
"sourcebucket": "src",
"endpoint": "minio.example:9000",
"credentials": {
"accessKey": "ak",
"secretKey": "sk",
"sessionToken": "minio-session-token"
},
"targetbucket": "dst",
"type": "replication",
"replicationSync": true,
"bandwidthlimit": 107374182400i64,
"storageclass": "STANDARD",
"resetID": "reset-789",
"deploymentID": "deploy-123"
}]
}))
.expect("MinIO-written bucket-targets.json must deserialize");
let target = &targets.targets[0];
assert_eq!(target.bandwidth_limit, 107374182400);
assert_eq!(target.storage_class, "STANDARD");
assert_eq!(target.reset_id, "reset-789");
assert_eq!(target.deployment_id, "deploy-123");
assert_eq!(
target
.credentials
.as_ref()
.and_then(|credentials| credentials.session_token.as_deref()),
Some("minio-session-token")
);
}
#[test]
fn test_bucket_target_debug_redacts_credentials() {
let target = BucketTarget {
+716 -86
View File
@@ -1120,6 +1120,48 @@ fn rollback_decommission_pool_meta(pool_meta: &mut PoolMeta, previous_pool_meta:
*pool_meta = previous_pool_meta;
}
#[derive(Debug)]
struct DecommissionCancelCommit {
previous_start_time: Option<OffsetDateTime>,
previous_queued: bool,
previous_last_update: OffsetDateTime,
canceled_pool: PoolStatus,
}
fn commit_decommission_cancel(pool_meta: &mut PoolMeta, idx: usize, commit: DecommissionCancelCommit) -> Result<()> {
let pool_count = pool_meta.pools.len();
let Some(current) = pool_meta.pools.get(idx) else {
return Err(invalid_decommission_pool_index_error(pool_count, idx));
};
// A peer reload can install the saved cancel while runtime-only fields are reconstructed.
let cancel_already_published = commit
.canceled_pool
.decommission
.as_ref()
.is_some_and(|info| info.canceled && !info.complete && !info.failed && info.start_time.is_none())
&& PersistedPoolStatus::from(current) == PersistedPoolStatus::from(&commit.canceled_pool);
if cancel_already_published {
return Ok(());
}
let matches_generation = current.id == commit.canceled_pool.id
&& current.cmd_line == commit.canceled_pool.cmd_line
&& current.decommission.as_ref().is_some_and(|info| {
info.start_time == commit.previous_start_time
&& info.queued == commit.previous_queued
&& is_decommission_active(info.complete, info.failed, info.canceled)
&& (commit.previous_start_time.is_some() || current.last_update == commit.previous_last_update)
});
if !matches_generation {
return Err(Error::other(format!(
"failed to publish decommission cancel for pool {idx}: operation generation changed"
)));
}
pool_meta.pools[idx] = commit.canceled_pool;
Ok(())
}
fn rollback_start_decommission_pool_meta(pool_meta: &mut PoolMeta, previous_pool_meta: PoolMeta) {
rollback_decommission_pool_meta(pool_meta, previous_pool_meta);
}
@@ -1570,7 +1612,7 @@ struct PersistedPoolMeta {
pub pools: Vec<PersistedPoolStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PersistedPoolStatus {
#[serde(rename = "id")]
@@ -1583,7 +1625,7 @@ struct PersistedPoolStatus {
pub decommission: Option<PersistedPoolDecommissionInfo>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PersistedPoolDecommissionInfo {
#[serde(rename = "startTime", with = "time::serde::rfc3339::option")]
@@ -3004,14 +3046,156 @@ impl ECStore {
}
#[tracing::instrument(skip(self))]
pub async fn decommission_cancel(&self, idx: usize) -> Result<()> {
pub async fn decommission_cancel(self: &Arc<Self>, idx: usize) -> Result<()> {
self.decommission_cancel_with_owner(idx, None).await
}
async fn decommission_cancel_for_operation(&self, idx: usize, owner: &DecommissionCanceler) -> Result<()> {
async fn decommission_cancel_for_operation(self: &Arc<Self>, idx: usize, owner: &DecommissionCanceler) -> Result<()> {
self.decommission_cancel_with_owner(idx, Some(owner)).await
}
async fn decommission_cancel_with_owner_and_save<Save, SaveFuture>(
self: &Arc<Self>,
idx: usize,
owner: Option<&DecommissionCanceler>,
save_pool_meta: Save,
) -> Result<()>
where
Save: FnOnce(PoolMeta) -> SaveFuture + Send + 'static,
SaveFuture: Future<Output = Result<()>> + Send + 'static,
{
let store = self.clone();
let owner = owner.cloned();
// Dropping the RPC waiter detaches this task; the transaction retains
// the store and exact owner until persistence is resolved.
tokio::spawn(async move { store.decommission_cancel_transaction(idx, owner, save_pool_meta).await })
.await
.map_err(|err| Error::other(format!("decommission cancel transaction task join error: {err}")))?
}
async fn decommission_cancel_transaction<Save, SaveFuture>(
&self,
idx: usize,
owner: Option<DecommissionCanceler>,
save_pool_meta: Save,
) -> Result<()>
where
Save: FnOnce(PoolMeta) -> SaveFuture,
SaveFuture: Future<Output = Result<()>>,
{
let owner = owner.as_ref();
ensure_decommission_terminal_operation_supported(self.single_pool(), "cancel decommission")?;
let _start_guard = self.start_gate.lock().await;
let operation_gate = self.ctx.decommission_operation_gate();
let operation_guard = operation_gate.write().await;
let save_guard = self.pool_meta_save_gate.lock().await;
// Lock order: start gate, operation gate, save gate,
// decommission_cancelers, then pool_meta. The state guards stay held
// across persistence so publication and owner termination are
// synchronous once the save reports success.
let mut cancelers = self.decommission_cancelers.write().await;
let mut pool_meta = self.pool_meta.write().await;
let (pending, should_reload_pool_meta, already_canceled, terminal_canceler) = {
let mut already_canceled = false;
let (pool_present, decommission_present, terminal) = if let Some(pool) = pool_meta.pools.get(idx) {
if let Some(info) = pool.decommission.as_ref() {
already_canceled = info.canceled;
(
true,
info.has_decommission_state(),
should_reject_decommission_cancel_as_terminal(info.complete, info.failed),
)
} else {
(true, false, false)
}
} else {
(false, false, false)
};
ensure_decommission_cancel_allowed(pool_present, decommission_present, terminal)?;
let previous_pool = pool_meta
.pools
.get(idx)
.cloned()
.ok_or_else(|| invalid_decommission_pool_index_error(pool_meta.pools.len(), idx))?;
let previous_decommission = previous_pool
.decommission
.as_ref()
.ok_or_else(|| decommission_metadata_not_initialized_error("cancel decommission"))?;
let mut snapshot = pool_meta.clone();
let Some(changed) = update_decommission_for_operation(cancelers.as_slice(), &mut snapshot, idx, owner, |pool_meta| {
pool_meta.decommission_cancel(idx)
}) else {
return Ok(());
};
let pending = if changed {
let canceled_pool = snapshot
.pools
.get(idx)
.cloned()
.ok_or_else(|| invalid_decommission_pool_index_error(pool_meta.pools.len(), idx))?;
Some((
snapshot,
DecommissionCancelCommit {
previous_start_time: previous_decommission.start_time,
previous_queued: previous_decommission.queued,
previous_last_update: previous_pool.last_update,
canceled_pool,
},
))
} else {
None
};
let terminal_canceler = if let Some(owner) = owner {
Some(owner.clone())
} else {
cancelers.get(idx).and_then(Option::as_ref).cloned()
};
(
pending,
should_retry_decommission_cancel_reload(changed, already_canceled),
already_canceled,
terminal_canceler,
)
};
let active_worker = terminal_canceler.as_ref().is_some_and(DecommissionCanceler::is_active);
if !active_worker && !already_canceled {
warn!(
event = EVENT_DECOMMISSION_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
state = "cancel_skipped",
reason = "no_active_canceler",
"Decommission cancel skipped"
);
}
let commit_result = if let Some((snapshot, commit)) = pending {
save_pool_meta(snapshot).await?;
commit_decommission_cancel(&mut pool_meta, idx, commit)
} else {
Ok(())
};
if let Some(canceler) = terminal_canceler.as_ref() {
take_and_cancel_decommission_canceler_for_operation(cancelers.as_mut_slice(), idx, canceler);
}
commit_result?;
drop(pool_meta);
drop(cancelers);
drop(save_guard);
drop(operation_guard);
if should_reload_pool_meta && let Some(notification_sys) = runtime_sources::notification_sys() {
let stage = format!("decommission_cancel for pool {idx}");
resolve_decommission_pool_meta_reload_result(notification_sys.reload_pool_meta().await, stage.as_str())?;
}
Ok(())
}
async fn release_decommission_canceler_slot(&self, idx: usize, owner: &DecommissionCanceler) {
let mut cancelers = self.decommission_cancelers.write().await;
take_and_cancel_decommission_canceler_for_operation(cancelers.as_mut_slice(), idx, owner);
@@ -3039,7 +3223,11 @@ impl ECStore {
retryable
}
async fn retry_decommission_cancel_for_operation(&self, idx: usize, owner: &DecommissionCanceler) {
async fn retry_decommission_cancel_for_operation(self: &Arc<Self>, idx: usize, owner: &DecommissionCanceler) {
if !self.decommission_terminal_retryable_for_operation(idx, owner).await {
return;
}
let mut attempt = 0usize;
loop {
let Err(err) = self.decommission_cancel_for_operation(idx, owner).await else {
@@ -3089,87 +3277,10 @@ impl ECStore {
}
}
async fn decommission_cancel_with_owner(&self, idx: usize, owner: Option<&DecommissionCanceler>) -> Result<()> {
ensure_decommission_terminal_operation_supported(self.single_pool(), "cancel decommission")?;
let _start_guard = self.start_gate.lock().await;
// Lock order: decommission_cancelers before pool_meta. Holding both makes
// owner validation and the terminal transition one atomic operation.
let (should_save_pool_meta, should_reload_pool_meta, already_canceled, previous_pool_meta, terminal_canceler) = {
let cancelers = self.decommission_cancelers.read().await;
let mut lock = self.pool_meta.write().await;
let mut already_canceled = false;
let (pool_present, decommission_present, terminal) = if let Some(pool) = lock.pools.get(idx) {
if let Some(info) = pool.decommission.as_ref() {
already_canceled = info.canceled;
(
true,
info.has_decommission_state(),
should_reject_decommission_cancel_as_terminal(info.complete, info.failed),
)
} else {
(true, false, false)
}
} else {
(false, false, false)
};
ensure_decommission_cancel_allowed(pool_present, decommission_present, terminal)?;
let previous_pool_meta = lock.clone();
let Some(changed) = update_decommission_for_operation(cancelers.as_slice(), &mut lock, idx, owner, |pool_meta| {
pool_meta.decommission_cancel(idx)
}) else {
return Ok(());
};
let terminal_canceler = if let Some(owner) = owner {
Some(owner.clone())
} else {
cancelers.get(idx).and_then(Option::as_ref).cloned()
};
if let Some(canceler) = terminal_canceler.as_ref() {
canceler.cancel();
}
(
changed,
should_retry_decommission_cancel_reload(changed, already_canceled),
already_canceled,
changed.then_some(previous_pool_meta),
terminal_canceler,
)
};
let canceled_worker = terminal_canceler.as_ref().is_some_and(DecommissionCanceler::is_active);
if !canceled_worker && !already_canceled {
warn!(
event = EVENT_DECOMMISSION_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
state = "cancel_skipped",
reason = "no_active_canceler",
"Decommission cancel skipped"
);
}
self.wait_for_decommission_side_effects().await;
if should_save_pool_meta && let Err(err) = self.save_current_pool_meta().await {
if let Some(previous_pool_meta) = previous_pool_meta {
let mut pool_meta = self.pool_meta.write().await;
rollback_decommission_pool_meta(&mut pool_meta, previous_pool_meta);
}
return Err(err);
}
if let Some(canceler) = terminal_canceler.as_ref() {
self.release_decommission_canceler_slot(idx, canceler).await;
}
if should_reload_pool_meta && let Some(notification_sys) = runtime_sources::notification_sys() {
let stage = format!("decommission_cancel for pool {idx}");
resolve_decommission_pool_meta_reload_result(notification_sys.reload_pool_meta().await, stage.as_str())?;
}
Ok(())
async fn decommission_cancel_with_owner(self: &Arc<Self>, idx: usize, owner: Option<&DecommissionCanceler>) -> Result<()> {
let pools = self.pools.clone();
self.decommission_cancel_with_owner_and_save(idx, owner, move |snapshot| async move { snapshot.save(pools).await })
.await
}
#[tracing::instrument(skip(self))]
@@ -9645,6 +9756,525 @@ mod pools_tests {
assert!(store.decommission_cancelers.read().await.iter().all(Option::is_none));
}
#[tokio::test]
async fn test_decommission_cancel_save_failure_preserves_generation_and_token_until_retry() {
let generation = OffsetDateTime::UNIX_EPOCH;
let canceler = DecommissionCanceler::new(CancellationToken::new());
let pool_meta = PoolMeta {
version: super::POOL_META_VERSION,
pools: vec![decommission_test_pool_status(
0,
Some(PoolDecommissionInfo {
start_time: Some(generation),
..Default::default()
}),
)],
..Default::default()
};
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
let err = store
.decommission_cancel_with_owner_and_save(0, Some(&canceler), |_| async { Err(Error::Timeout) })
.await
.expect_err("injected pool metadata timeout should fail cancel");
assert!(matches!(err, Error::Timeout));
{
let pool_meta = store.pool_meta.read().await;
let info = pool_meta.pools[0]
.decommission
.as_ref()
.expect("failed cancel must retain decommission metadata");
assert_eq!(info.start_time, Some(generation));
assert!(!info.canceled);
assert!(!info.complete);
assert!(!info.failed);
assert!(ensure_decommission_generation(&pool_meta, 0, generation).is_ok());
}
{
let cancelers = store.decommission_cancelers.read().await;
let current = cancelers[0].as_ref().expect("failed cancel must retain the worker owner");
assert!(current.owns_same_operation(&canceler));
assert!(current.is_active());
}
assert!(!canceler.is_cancelled());
assert!(store.decommission_terminal_retryable_for_operation(0, &canceler).await);
let persisted = Arc::new(std::sync::Mutex::new(None));
let persisted_for_save = persisted.clone();
store
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |snapshot| async move {
let data = snapshot.encode_config_data()?;
*persisted_for_save
.lock()
.expect("persisted snapshot lock should not be poisoned") = Some(data);
Ok(())
})
.await
.expect("cancel retry should commit");
{
let pool_meta = store.pool_meta.read().await;
let info = pool_meta.pools[0]
.decommission
.as_ref()
.expect("committed cancel metadata should remain present");
assert!(info.canceled);
assert!(!info.complete);
assert!(!info.failed);
assert!(info.start_time.is_none());
}
assert!(store.decommission_cancelers.read().await[0].is_none());
assert!(!canceler.is_active());
assert!(canceler.is_cancelled());
let repeated_save_called = Arc::new(AtomicBool::new(false));
let repeated_save_called_by_closure = repeated_save_called.clone();
store
.decommission_cancel_with_owner_and_save(0, None, move |_| async move {
repeated_save_called_by_closure.store(true, Ordering::SeqCst);
Ok(())
})
.await
.expect("repeated cancel should be idempotent");
assert!(!repeated_save_called.load(Ordering::SeqCst));
let persisted = persisted
.lock()
.expect("persisted snapshot lock should not be poisoned")
.take()
.expect("successful retry should capture persisted bytes");
let mut restarted = PoolMeta::default();
restarted
.load_from_config_data(persisted)
.expect("a restarted process should decode the committed cancel");
assert!(
restarted.pools[0]
.decommission
.as_ref()
.is_some_and(|info| info.canceled && info.start_time.is_none())
);
assert!(first_resumable_decommission_queue_indices(&restarted).is_empty());
}
#[tokio::test]
async fn test_decommission_cancel_serializes_reload_until_local_commit() {
let generation = OffsetDateTime::UNIX_EPOCH;
let canceler = DecommissionCanceler::new(CancellationToken::new());
let pool_meta = PoolMeta {
version: super::POOL_META_VERSION,
pools: vec![decommission_test_pool_status(
0,
Some(PoolDecommissionInfo {
start_time: Some(generation),
stage: "migrate_object".to_string(),
..Default::default()
}),
)],
..Default::default()
};
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel();
let save_release = Arc::new(tokio::sync::Notify::new());
let cancel = tokio::spawn({
let store = store.clone();
let canceler = canceler.clone();
let save_release = save_release.clone();
async move {
store
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |snapshot| async move {
persisted_tx
.send(snapshot.encode_config_data()?)
.map_err(|_| Error::other("failed to expose saved cancel snapshot"))?;
save_release.notified().await;
Ok(())
})
.await
}
});
let persisted = persisted_rx.await.expect("save should expose the canceled snapshot");
let reload_started = Arc::new(tokio::sync::Notify::new());
let reload = tokio::spawn({
let store = store.clone();
let reload_started = reload_started.clone();
async move {
let mut reloaded = PoolMeta::default();
reloaded.load_from_config_data(persisted)?;
reload_started.notify_one();
*store.pool_meta.write().await = reloaded;
Ok::<(), Error>(())
}
});
reload_started.notified().await;
assert!(!reload.is_finished(), "peer reload must wait for cancel publication");
save_release.notify_one();
cancel
.await
.expect("cancel task should not panic")
.expect("cancel should commit before releasing the reload");
reload
.await
.expect("reload task should not panic")
.expect("reload should install the saved cancel after publication");
let pool_meta = store.pool_meta.read().await;
let info = pool_meta.pools[0]
.decommission
.as_ref()
.expect("reloaded cancel metadata should remain present");
assert!(info.canceled);
assert!(!info.complete);
assert!(!info.failed);
assert!(info.start_time.is_none());
assert!(info.stage.is_empty(), "the persisted snapshot should have been decoded before commit");
drop(pool_meta);
assert!(store.decommission_cancelers.read().await[0].is_none());
assert!(!canceler.is_active());
assert!(canceler.is_cancelled());
}
#[tokio::test]
async fn test_decommission_cancel_transaction_survives_caller_abort_after_durable_save() {
let generation = OffsetDateTime::UNIX_EPOCH;
let canceler = DecommissionCanceler::new(CancellationToken::new());
let pool_meta = PoolMeta {
pools: vec![decommission_test_pool_status(
0,
Some(PoolDecommissionInfo {
start_time: Some(generation),
..Default::default()
}),
)],
..Default::default()
};
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
let persisted = Arc::new(std::sync::Mutex::new(None));
let (durable_tx, durable_rx) = tokio::sync::oneshot::channel();
let save_release = Arc::new(tokio::sync::Notify::new());
let cancel = tokio::spawn({
let store = store.clone();
let canceler = canceler.clone();
let persisted = persisted.clone();
let save_release = save_release.clone();
async move {
store
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |snapshot| async move {
assert!(
snapshot.pools[0]
.decommission
.as_ref()
.is_some_and(|info| info.canceled && info.start_time.is_none())
);
*persisted.lock().expect("persisted cancel lock should not be poisoned") =
Some(snapshot.encode_config_data()?);
durable_tx
.send(())
.map_err(|_| Error::other("failed to report durable cancel snapshot"))?;
save_release.notified().await;
Ok(())
})
.await
}
});
durable_rx.await.expect("save hook should report the durable cancel snapshot");
cancel.abort();
let join_err = cancel.await.expect_err("caller cancel future should be aborted");
assert!(join_err.is_cancelled());
assert!(
store.pool_meta.try_read().is_err(),
"the detached transaction must retain its state guard"
);
assert!(
store.decommission_cancelers.try_read().is_err(),
"the detached transaction must retain its owner guard"
);
save_release.notify_one();
tokio::time::timeout(StdDuration::from_secs(1), canceler.token().cancelled())
.await
.expect("detached cancel transaction should terminate the old token");
let persisted = persisted
.lock()
.expect("persisted cancel lock should not be poisoned")
.take()
.expect("save should capture the durable cancel snapshot");
let mut durable = PoolMeta::default();
durable
.load_from_config_data(persisted)
.expect("durable cancel snapshot should decode");
assert!(
durable.pools[0]
.decommission
.as_ref()
.is_some_and(|info| info.canceled && info.start_time.is_none())
);
assert!(store.decommission_cancelers.read().await[0].is_none());
assert!(!canceler.is_active());
assert!(canceler.is_cancelled());
let side_effect_ran = Arc::new(AtomicBool::new(false));
let operation_gate = store.ctx.decommission_operation_gate();
let result = run_decommission_side_effect(canceler.token(), &operation_gate, {
let side_effect_ran = side_effect_ran.clone();
move || async move {
side_effect_ran.store(true, Ordering::SeqCst);
Ok(())
}
})
.await;
assert!(matches!(result, Err(Error::OperationCanceled)));
assert!(!side_effect_ran.load(Ordering::SeqCst));
assert!(
store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.is_some_and(|info| info.canceled && !info.failed)
);
}
#[tokio::test]
async fn test_decommission_cancel_quiesces_and_publishes_only_after_save() {
let generation = OffsetDateTime::UNIX_EPOCH;
let canceler = DecommissionCanceler::new(CancellationToken::new());
let pool_meta = PoolMeta {
pools: vec![decommission_test_pool_status(
0,
Some(PoolDecommissionInfo {
start_time: Some(generation),
..Default::default()
}),
)],
..Default::default()
};
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
let operation_gate = store.ctx.decommission_operation_gate();
let side_effect = operation_gate.read().await;
let save_started = Arc::new(tokio::sync::Notify::new());
let save_release = Arc::new(tokio::sync::Notify::new());
let save_entered = Arc::new(AtomicBool::new(false));
let mut cancel = tokio::spawn({
let store = store.clone();
let canceler = canceler.clone();
let save_started = save_started.clone();
let save_release = save_release.clone();
let save_entered = save_entered.clone();
async move {
store
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |_| async move {
save_entered.store(true, Ordering::SeqCst);
save_started.notify_one();
save_release.notified().await;
Ok(())
})
.await
}
});
let mut cancel_holds_start_gate = false;
for _ in 0..100 {
if store.start_gate.try_lock().is_err() {
cancel_holds_start_gate = true;
break;
}
tokio::task::yield_now().await;
}
assert!(cancel_holds_start_gate, "cancel should reach the operation-gate wait");
assert!(!cancel.is_finished(), "cancel must wait for the in-flight side effect");
assert!(!save_entered.load(Ordering::SeqCst), "cancel must quiesce side effects before saving");
{
let pool_meta = store.pool_meta.read().await;
assert!(ensure_decommission_generation(&pool_meta, 0, generation).is_ok());
}
assert!(!canceler.is_cancelled());
drop(side_effect);
tokio::time::timeout(StdDuration::from_secs(1), save_started.notified())
.await
.expect("cancel should reach the injected save after side effects quiesce");
assert!(
store.pool_meta_save_gate.try_lock().is_err(),
"the cancel save must exclude stale full-document saves until publication"
);
assert!(
store.pool_meta.try_read().is_err(),
"the active generation must stay write-locked through persistence"
);
assert!(!canceler.is_cancelled(), "the token must remain live until persistence commits");
let mut complete = tokio::spawn({
let store = store.clone();
async move { store.complete_decommission(0).await }
});
let mut fail = tokio::spawn({
let store = store.clone();
async move { store.decommission_failed(0).await }
});
tokio::task::yield_now().await;
assert!(!complete.is_finished(), "complete must serialize behind the pending cancel");
assert!(!fail.is_finished(), "fail must serialize behind the pending cancel");
save_release.notify_one();
tokio::time::timeout(StdDuration::from_secs(1), &mut cancel)
.await
.expect("cancel should finish after persistence commits")
.expect("cancel task should not panic")
.expect("cancel should commit");
tokio::time::timeout(StdDuration::from_secs(1), &mut complete)
.await
.expect("complete should finish after cancel commits")
.expect("complete task should not panic")
.expect("stale complete should be a no-op");
tokio::time::timeout(StdDuration::from_secs(1), &mut fail)
.await
.expect("fail should finish after cancel commits")
.expect("fail task should not panic")
.expect("stale fail should be a no-op");
let pool_meta = store.pool_meta.read().await;
let info = pool_meta.pools[0]
.decommission
.as_ref()
.expect("cancel metadata should remain present");
assert!(info.canceled);
assert!(!info.complete);
assert!(!info.failed);
assert!(info.start_time.is_none());
drop(pool_meta);
assert!(canceler.is_cancelled());
assert!(!canceler.is_active());
assert!(store.decommission_cancelers.read().await[0].is_none());
}
#[test]
fn test_decommission_cancel_commit_rejects_a_replaced_generation() {
let old_generation = OffsetDateTime::UNIX_EPOCH;
let new_generation = old_generation + Duration::seconds(1);
let mut canceled = PoolMeta {
pools: vec![decommission_test_pool_status(
0,
Some(PoolDecommissionInfo {
start_time: Some(old_generation),
..Default::default()
}),
)],
..Default::default()
};
let previous_last_update = canceled.pools[0].last_update;
assert!(canceled.decommission_cancel(0));
let canceled_pool = canceled.pools.remove(0);
let commit = super::DecommissionCancelCommit {
previous_start_time: Some(old_generation),
previous_queued: false,
previous_last_update,
canceled_pool,
};
let mut current = PoolMeta {
pools: vec![decommission_test_pool_status(
0,
Some(PoolDecommissionInfo {
start_time: Some(new_generation),
..Default::default()
}),
)],
..Default::default()
};
let err = super::commit_decommission_cancel(&mut current, 0, commit)
.expect_err("an old cancel must not publish over a replacement generation");
assert!(err.to_string().contains("operation generation changed"));
let info = current.pools[0]
.decommission
.as_ref()
.expect("replacement generation should remain present");
assert_eq!(info.start_time, Some(new_generation));
assert!(!info.canceled);
}
#[tokio::test]
async fn test_decommission_cancel_rejects_stale_retry_after_queued_replacement() {
let old_generation = OffsetDateTime::UNIX_EPOCH;
let canceler = DecommissionCanceler::new(CancellationToken::new());
let pool_meta = PoolMeta {
pools: vec![decommission_test_pool_status(
0,
Some(PoolDecommissionInfo {
start_time: Some(old_generation),
..Default::default()
}),
)],
..Default::default()
};
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
let queued_replacement = Arc::new(std::sync::Mutex::new(None));
let queued_replacement_for_save = queued_replacement.clone();
store
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |snapshot| async move {
let saved_cancel = snapshot.pools[0]
.decommission
.as_ref()
.expect("saved snapshot should contain decommission metadata");
assert!(saved_cancel.canceled);
assert!(saved_cancel.start_time.is_none());
let mut queued_replacement = snapshot.clone();
let replacement = queued_replacement
.pools
.get_mut(0)
.expect("cancel snapshot should contain the pool");
replacement.last_update += Duration::seconds(1);
let info = replacement
.decommission
.as_mut()
.expect("cancel snapshot should contain decommission metadata");
info.canceled = false;
info.queued = true;
*queued_replacement_for_save
.lock()
.expect("queued replacement lock should not be poisoned") = Some(queued_replacement);
Ok(())
})
.await
.expect("cancel should commit before a queued replacement is installed");
assert!(store.decommission_cancelers.read().await[0].is_none());
assert!(!canceler.is_active());
assert!(canceler.is_cancelled());
let queued_replacement = queued_replacement
.lock()
.expect("queued replacement lock should not be poisoned")
.take()
.expect("save should prepare the queued replacement");
*store.pool_meta.write().await = queued_replacement;
let replacement_revision = {
let pool_meta = store.pool_meta.read().await;
let info = pool_meta.pools[0]
.decommission
.as_ref()
.expect("queued replacement should remain present");
assert!(info.queued);
assert!(!info.canceled);
assert!(info.start_time.is_none());
pool_meta.pools[0].last_update
};
store.retry_decommission_cancel_for_operation(0, &canceler).await;
let pool_meta = store.pool_meta.read().await;
let info = pool_meta.pools[0]
.decommission
.as_ref()
.expect("stale retry must preserve the queued replacement");
assert_eq!(pool_meta.pools[0].last_update, replacement_revision);
assert!(info.queued);
assert!(!info.canceled);
assert!(info.start_time.is_none());
}
#[tokio::test]
async fn test_decommission_failed_save_failure_preserves_owner_until_retry_succeeds() {
let canceler = DecommissionCanceler::new(CancellationToken::new());
+14 -1
View File
@@ -2124,13 +2124,26 @@ impl SetDisks {
let put_object_size = known_put_object_storage_size(data.size());
let shard_file_size_raw = erasure.shard_file_size(put_object_size);
let is_inline_buffer = storage_class_config.should_inline(shard_file_size_raw, erasure.data_shards, opts.versioned);
let is_inline_buffer =
storage_class_config.should_inline(shard_file_size_raw, erasure.data_shards, opts.versioned);
let collect_stage_timing = rustfs_io_metrics::put_stage_metrics_enabled() || issue3031_diag_enabled();
let shard_file_size = shard_file_size_raw;
let shard_size = erasure.shard_size();
let write_path = classify_put_write_path(is_inline_buffer, put_object_size, fi.erasure.block_size);
let direct_inline_commit = matches!(write_path, SmallWritePath::Inline);
{
use std::io::Write;
let msg = format!(
"INLINE_DEBUG: bucket={} obj={} size={} shard_fs={} ds={} bs={} inline={} direct={} path={} iblock={} ver={}\n",
bucket, object, put_object_size, shard_file_size_raw, erasure.data_shards, fi.erasure.block_size,
is_inline_buffer, direct_inline_commit, write_path.metric_label(), storage_class_config.inline_block(), opts.versioned
);
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/tmp/rustfs_inline_debug.log") {
let _ = f.write_all(msg.as_bytes());
}
let _ = std::io::stderr().write_all(msg.as_bytes());
}
rustfs_io_metrics::record_put_object_path(write_path.metric_label());
let writer_setup_stage_start = collect_stage_timing.then(Instant::now);
let (mut writers, errors) = if direct_inline_commit {
+1 -1
View File
@@ -3194,7 +3194,7 @@ impl ECStore {
// Default return value
let mut del_objects = vec![DeletedObject::default(); objects.len()];
let accounting = vec![None; objects.len()];
let mut accounting = vec![None; objects.len()];
let mut del_errs = Vec::with_capacity(objects.len());
for _ in 0..objects.len() {
@@ -271,7 +271,7 @@ pub(super) fn resolve_latest_object_info_candidates(
.filter(|candidate| latest_candidate_mod_time(candidate) == Some(latest_mod_time))
.collect::<Vec<_>>();
latest_candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.idx));
latest_candidates.sort_by(|left, right| right.idx.cmp(&left.idx));
let Some(winner) = latest_candidates.first() else {
return Err(Error::ErasureReadQuorum);
+3
View File
@@ -43,6 +43,9 @@ allow-git = [
# RustFS fork carrying presigned expiry and constant-time authentication fixes.
# owner: rustfs-maintainers review: 2026-10
"https://github.com/rustfs/s3s.git",
# MiMalloc fork pinned for hotpath allocation counting support.
# owner: houseme review: 2026-10
"https://github.com/xonatius/mimalloc_rust.git",
]
[bans]
-149
View File
@@ -1,149 +0,0 @@
# CI gate matrix
This file is the source of truth for which validation runs on each event, its
configured wall-clock budget, and whether it can block a merge. Test taxonomy,
naming, and nextest serialization rules remain in [README.md](README.md); e2e
membership and counts remain in
[e2e-suite-inventory.md](e2e-suite-inventory.md).
The distinction between **required** and **report-only** is load-bearing:
a failing job blocks a merge only when its exact check name is present in the
live `main` ruleset. A workflow name, a `merge_group` trigger, or a red PR check
does not make a job required by itself.
## Required merge checks
The live `main` ruleset (`6436880`) currently requires exactly these contexts:
| Required context | Producer | Validation |
|---|---|---|
| `CLA Check` | `.github/workflows/cla.yml` | Contributor agreement |
| `Quick Checks` | `.github/workflows/ci.yml` | Formatting and repository guard scripts |
| `Test and Lint` | `.github/workflows/ci.yml` | Clippy, workspace nextest excluding `e2e_test`, doctests, and migration proofs |
For pull requests limited to the paths excluded by the main CI workflow,
`.github/workflows/ci-docs-only.yml` reports `Quick Checks` and
`Test and Lint` under the same names. It runs the real quick checks and the
planning-document guard; it does not claim that Rust compilation or runtime
tests ran. Despite the workflow name, these paths also include selected deploy,
workflow, and lock files.
Verify the live rule rather than trusting this snapshot before changing merge
policy:
```bash
gh api repos/rustfs/rustfs/rulesets/6436880 \
--jq '.rules[] | select(.type == "required_status_checks") | .parameters'
```
The ruleset currently has `strict_required_status_checks_policy=false`.
`Continuous Integration` accepts `merge_group` events and runs `e2e-full` for
them, but `End-to-End Tests (full merge gate)` is not currently a required
context. Therefore the repository is prepared to test a merge-queue SHA, but
the workflow alone does not prove that every merge passed that lane.
## Pull request and merge matrix
Budgets below are job `timeout-minutes`, not typical runtimes. “Report-only”
means the result is visible and actionable but is not in the live required
context list.
| Event | Validation | Budget | Merge status | Reproduction |
|---|---|---:|---|---|
| PR, non-doc change | `Quick Checks` | 10 min | Required | `make pre-commit` (broader local umbrella) |
| PR, non-doc change | `Test and Lint` | 90 min | Required | `cargo nextest run --profile ci --all --exclude e2e_test` |
| PR, non-doc change | `Typos` | 10 min | Report-only | `typos` |
| PR, non-doc change | `ILM Integration (serial)` | 90 min | Report-only | Use the exact command in `.github/workflows/ci.yml` |
| PR, non-doc change | rio-v2 / swift / sftp test-and-lint variants | 90 min each | Report-only | `cargo nextest run` with the workflow's feature set |
| PR, non-doc change | `Build RustFS Debug Binary` | 30 min | Report-only; prerequisite for black-box lanes | `cargo build -p rustfs --bins` |
| PR, non-doc change | `io_uring Integration (real)` | 30 min | Report-only | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
| PR, non-doc change | `End-to-End Tests` (`e2e-smoke` plus `s3s-e2e`) | 30 min | Report-only | `cargo nextest run --profile e2e-smoke -p e2e_test`; then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>` |
| PR, non-doc change | `S3 Implemented Tests` | 60 min | Report-only | Build `rustfs`, then run `scripts/s3-tests/run.sh` with `DEPLOY_MODE=binary`, `TEST_MODE=single`, and `MAXFAIL=0` |
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | 30 min | Report-only | Use the accelerated scanner environment in `.github/workflows/ci.yml` with `scripts/s3-tests/run.sh` |
| PR touching dependency or workflow inputs | Cargo Deny / Workflow Pin Report / Dependency Review | 20 / 5 / 30 min | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
| PR touching architecture rules or architecture docs | `Architecture Migration Rules` | 10 min | Report-only | `scripts/check_architecture_migration_rules.sh` |
| PR touching Nix or workspace manifests | `Nix Build & Check` | 60 min | Report-only | `nix flake check` |
| PR limited to main-CI-excluded paths | companion `Quick Checks` and `Test and Lint` | 10 min each | Required | `git diff --check`; `make doc-paths-check` when documentation paths changed |
| `merge_group` | Standard CI plus `e2e-full` | 55 min for `e2e-full` | Standard required contexts only; `e2e-full` report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
| Push to `main` | Standard CI plus `e2e-full` | 55 min for `e2e-full` | Post-merge detection | Same as `merge_group` |
| PR touching fuzz inputs or harness paths | Build plus five 60-second fuzz smoke targets | 60 min build; 30 min per target | Report-only | `MAX_TOTAL_TIME=60 ./scripts/fuzz/run.sh` |
| PR touching selected ecstore disk/format paths | `Rename Safety` on Windows | 60 min | Report-only | Run the four `cargo test -p rustfs-ecstore --lib <filter>` commands in `windows-filesystem.yml` on Windows |
The authoritative e2e filters live in `.config/nextest.toml`; extend a profile
instead of adding a second ad-hoc selector. Before a profile runs,
`scripts/check_test_wiring.py` compares its exact membership to the committed
digest so a silent test drop fails closed.
## Scheduled and manual validation
Scheduled lanes are independent fault domains. They do not block a pull
request, but their workflow-local gate can fail the run and scheduled failures
are routed to the shared failure-issue action. The scheduled-validation
watchdog and freshness workflow separately detect incomplete runs and missing
schedules.
| Cadence (UTC unless noted) | Workflow / validation | Budget | Verdict and artifacts | Reproduction |
|---|---|---:|---|---|
| Daily 02:17 | Fuzz: five nightly corpus targets | 60 min build; 60 min per target | Gate; corpus/crash artifacts, scheduled failure alert | `MAX_TOTAL_TIME=<seconds> ./scripts/fuzz/run.sh` |
| Daily 03:17 | MinIO interop (EC + SSE read parity) | 40 min | Gate; scheduled failure alert | Dispatch `minio-interop.yml` or follow its pinned Docker fixture steps |
| Daily 04:29 | Replication / cluster-fault / protocol e2e | 45 / 90 / 90 min | Three independent gates; JUnit, membership, and server logs | `cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--profile e2e-nightly`; `-j 1 --profile e2e-protocols` |
| Daily 06:31 | Warp performance A/B | 180 min | Regression budget gate; A/B summaries and server logs | `bash scripts/run_hotpath_warp_abba.sh --help` |
| Daily 00:07 Asia/Shanghai (16:07 UTC previous day) | Nightly GNU build and Vault lanes | 150 / 90 / 60 min | Build, live Vault, and HA failover gates | Use the commands and pinned Vault images in `nightly-gnu.yml` |
| Daily 03:23 | Security Audit | 20 / 5 min, plus 30 min on PR dependency review | Cargo Deny and workflow-pin gates; scheduled failure alert | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
| Daily 23:47 | Scheduled Validation Freshness | 10 min | Fails when a critical schedule was never created or is stale | Dispatch `scheduled-validation-freshness.yml` |
| Sunday 00:11 | Full `Continuous Integration` matrix | Per-job budgets above | Weekly variant coverage, including dormant rio-v2 binary/e2e lanes | Dispatch `ci.yml` |
| Sunday 01:13 | Seven-platform build matrix | 150 min per platform | Build/package integrity; scheduled failure alert | Dispatch `build.yml` with an exact platform set |
| Sunday 02:19 | Ceph s3-tests full sweep: single and real four-node, four shards each | 180 min per shard | Compatibility gate; report, JUnit, exact node IDs, and server logs | `scripts/s3-tests/run.sh` against an existing single or distributed target |
| Sunday 06:41 | Mint | 120 min | **Report-only by design**; per-suite PASS/FAIL/NA and raw `log.json` | Reproduce the pinned Docker sequence in `mint.yml` or dispatch it |
| Sunday 07:43 | Workspace line coverage | 120 min | Report-only trend; lcov and JSON retained 90 days | `make coverage` |
| Monthly, day 1 06:37 | Runner Hygiene | 15 min | Validates runner ephemerality; scheduled failure alert | Dispatch `runner-hygiene.yml` |
Manual `workflow_dispatch` exists for the scheduled workflows above. Manual
runs are debugging evidence and intentionally do not open scheduled-failure
issues. A manual performance run may explicitly allow a known regression; that
override must not be treated as an ordinary passing baseline.
## Release validation
Release validation is post-merge and tag-driven; it does not substitute for a
pull-request gate.
| Event | Validation | Budget | Result |
|---|---|---:|---|
| Push to `main` or weekly schedule | `Build and Release` platform matrix | 150 min per platform | Build artifacts for all selected targets; no release publication on a main push |
| Valid release or preview tag | `Build and Release` plus asset checks | 150 min per platform | Draft release, checksummed assets, and publish step |
| Successful non-preview release-tag build | Docker image build and image scan | 60 min build; 30 min scan | Multi-architecture images plus vulnerability report |
| Successful release-tag build | DEB/RPM packaging | 30 min per architecture | Packages and checksum files uploaded to the release |
| Successful non-preview release-tag build | Helm template test and package | 30 min build; 30 min publish | Versioned chart and repository index |
Use an exact preview tag for end-to-end release rehearsal. Manual dispatches
are backfill/debug paths and do not prove the automatic `workflow_run` chain.
## Evidence requirements
A green check is useful only when it proves the intended behavior ran:
- Record the exact commit SHA and run URL.
- Separate product failure from runner prerequisites, service readiness, and
cancellation. Repair the precondition, then rerun the exact workload.
- Preserve membership manifests, JUnit, raw compatibility logs, seeds, and
server logs where the workflow provides them.
- For a bug fix or a new fault checker, provide sensitivity evidence: the old
behavior or an intentional mutation must fail the new oracle, and the fixed
behavior must pass it.
- Never promote a report-only lane to required from one green run. Require at
least 14 days and 30 representative pull requests with at least 99% complete
execution, then update the ruleset and this table together.
## Change checklist
Update this file in the same pull request when any of these change:
- workflow triggers, job names, timeouts, or nextest profile ownership;
- required status contexts or strict/merge-queue policy;
- scheduled cadence, alert routing, artifact contract, or local reproduction;
- report-only versus gating semantics.
Do not copy per-module test counts here. Update
[e2e-suite-inventory.md](e2e-suite-inventory.md) and its enforced membership
digest instead.
+2 -2
View File
@@ -336,13 +336,13 @@ opentelemetry = { workspace = true }
tracing-opentelemetry = { workspace = true }
# Data structures
hashbrown = { workspace = true, features = ["serde", "rayon"] }
rustfs-mimalloc = { workspace = true }
mimalloc = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
libsystemd.workspace = true
[target.'cfg(not(target_os = "windows"))'.dependencies]
rustfs-mimalloc-sys.workspace = true
libmimalloc-sys.workspace = true
[dev-dependencies]
uuid = { workspace = true, features = ["v4", "v5", "fast-rng", "macro-diagnostics"] }
+2 -139
View File
@@ -376,10 +376,7 @@ impl RemoteTargetRequest {
/// Admin-response encoding of a remote target: the persisted bucket-targets
/// format keeps `healthCheckDuration`/`totalDowntime` in seconds and the
/// `latency` stats in milliseconds, but madmin decodes all of them as Go
/// `time.Duration` (nanoseconds) — and it looks the fields up under its own
/// JSON tags (`bandwidthlimit`, `storageclass`, `resetID`, `deploymentID`,
/// `credentials.sessionToken` — madmin-go v3.0.109 `bucket-targets.go`), not
/// the persisted snake_case keys. Re-encode just those fields here without
/// `time.Duration` (nanoseconds) — re-encode just those fields without
/// touching the persistence wire format.
fn remote_target_admin_json(target: &BucketTarget) -> Result<serde_json::Value, serde_json::Error> {
fn go_duration_nanos(duration: Duration) -> serde_json::Value {
@@ -388,12 +385,6 @@ fn remote_target_admin_json(target: &BucketTarget) -> Result<serde_json::Value,
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX).into()
}
fn rename_key(value: &mut serde_json::Value, from: &str, to: &str) {
if let Some(moved) = value.as_object_mut().and_then(|object| object.remove(from)) {
value[to] = moved;
}
}
let mut value = serde_json::to_value(target)?;
value["healthCheckDuration"] = go_duration_nanos(target.health_check_duration);
value["totalDowntime"] = go_duration_nanos(target.total_downtime);
@@ -402,11 +393,6 @@ fn remote_target_admin_json(target: &BucketTarget) -> Result<serde_json::Value,
"avg": go_duration_nanos(target.latency.avg),
"max": go_duration_nanos(target.latency.max),
});
rename_key(&mut value, "bandwidth_limit", "bandwidthlimit");
rename_key(&mut value, "storage_class", "storageclass");
rename_key(&mut value, "reset_id", "resetID");
rename_key(&mut value, "deployment_id", "deploymentID");
rename_key(&mut value["credentials"], "session_token", "sessionToken");
Ok(value)
}
@@ -1490,7 +1476,7 @@ mod tests {
parse_remote_target_update_ops, render_mrf_backlog, render_replication_diff, unique_replication_peers,
validate_remote_target_tls_settings,
};
use crate::admin::storage_api::bucket::target::{BucketTarget, Credentials as TargetCredentials, LatencyStat};
use crate::admin::storage_api::bucket::target::{BucketTarget, LatencyStat};
use crate::admin::storage_api::replication::{BucketStats, DurableMrfBacklog, MrfOpKind, MrfReplicateEntry};
use http::Uri;
@@ -2286,129 +2272,6 @@ mod tests {
assert_eq!(persisted["latency"]["max"], 250);
}
#[test]
fn list_remote_targets_response_uses_madmin_key_names() {
// madmin-go v3.0.109 BucketTarget JSON tags are `bandwidthlimit`,
// `storageclass`, `resetID`, `deploymentID`, and
// `credentials.sessionToken` (backlog#1951); the persisted snake_case
// keys decode to zero values in mc, blanking the bandwidth and
// reset-id columns of `mc replicate ls`.
let target = BucketTarget {
endpoint: "192.168.1.10:9000".to_string(),
target_bucket: "target".to_string(),
credentials: Some(TargetCredentials {
access_key: "access".to_string(),
secret_key: String::new(),
session_token: Some("session-token".to_string()),
expiration: None,
}),
bandwidth_limit: 107_374_182_400,
storage_class: "STANDARD".to_string(),
reset_id: "reset-123".to_string(),
deployment_id: "deploy-456".to_string(),
..Default::default()
};
let value = super::remote_target_admin_json(&target).expect("admin response should serialize");
assert_eq!(value["bandwidthlimit"], 107_374_182_400i64);
assert_eq!(value["storageclass"], "STANDARD");
assert_eq!(value["resetID"], "reset-123");
assert_eq!(value["deploymentID"], "deploy-456");
assert_eq!(value["credentials"]["sessionToken"], "session-token");
// The madmin keys replace the snake_case ones rather than duplicating
// them next to each other.
for stale in ["bandwidth_limit", "bandwidth", "storage_class", "reset_id", "deployment_id"] {
assert!(value.get(stale).is_none(), "admin response must not carry `{stale}`");
}
assert!(value["credentials"].get("session_token").is_none());
}
/// Decode-side mirror of madmin-go v3.0.109 `BucketTarget`/`Credentials`
/// (`bucket-targets.go`): the exact `json:"..."` tags mc's `encoding/json`
/// looks fields up under. Unknown keys are ignored like Go does, and a
/// missing key leaves the Go zero value, which is exactly how a misnamed
/// key turns into a blank column in `mc replicate ls`.
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
struct MadminBucketTarget {
sourcebucket: String,
endpoint: String,
credentials: Option<MadminCredentials>,
targetbucket: String,
arn: String,
bandwidthlimit: i64,
#[serde(rename = "replicationSync")]
replication_sync: bool,
storageclass: String,
#[serde(rename = "healthCheckDuration")]
health_check_duration: i64,
#[serde(rename = "resetID")]
reset_id: String,
#[serde(rename = "totalDowntime")]
total_downtime: i64,
#[serde(rename = "deploymentID")]
deployment_id: String,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
struct MadminCredentials {
#[serde(rename = "accessKey")]
access_key: String,
#[serde(rename = "secretKey")]
secret_key: String,
#[serde(rename = "sessionToken")]
session_token: String,
}
#[test]
fn list_remote_targets_response_decodes_through_madmin_tags() {
// Regression for the review on backlog#1951: the response must decode
// a nonzero bandwidth limit through madmin's `bandwidthlimit` tag (not
// `bandwidth`, which Go would silently drop as an unknown key).
let target = BucketTarget {
source_bucket: "src".to_string(),
endpoint: "192.168.1.10:9000".to_string(),
target_bucket: "target".to_string(),
arn: "arn:rustfs:replication:us-east-1:dep:target".to_string(),
credentials: Some(TargetCredentials {
access_key: "access".to_string(),
secret_key: String::new(),
session_token: Some("session-token".to_string()),
expiration: None,
}),
bandwidth_limit: 1_073_741_824,
replication_sync: true,
storage_class: "STANDARD".to_string(),
health_check_duration: std::time::Duration::from_secs(60),
reset_id: "reset-123".to_string(),
total_downtime: std::time::Duration::from_secs(90),
deployment_id: "deploy-456".to_string(),
..Default::default()
};
let wire = serde_json::to_string(&super::remote_target_admin_json(&target).expect("admin response should serialize"))
.expect("admin response should encode");
let decoded: MadminBucketTarget = serde_json::from_str(&wire).expect("madmin-shaped decode must succeed");
assert_eq!(decoded.bandwidthlimit, 1_073_741_824, "mc must see the nonzero bandwidth limit");
assert_eq!(decoded.sourcebucket, "src");
assert_eq!(decoded.endpoint, "192.168.1.10:9000");
assert_eq!(decoded.targetbucket, "target");
assert_eq!(decoded.arn, "arn:rustfs:replication:us-east-1:dep:target");
assert!(decoded.replication_sync);
assert_eq!(decoded.storageclass, "STANDARD");
assert_eq!(decoded.health_check_duration, 60_000_000_000);
assert_eq!(decoded.reset_id, "reset-123");
assert_eq!(decoded.total_downtime, 90_000_000_000);
assert_eq!(decoded.deployment_id, "deploy-456");
let credentials = decoded.credentials.expect("credentials must decode");
assert_eq!(credentials.access_key, "access");
assert_eq!(credentials.secret_key, "");
assert_eq!(credentials.session_token, "session-token");
}
#[test]
fn remote_target_admin_json_latency_round_trips_through_go_duration() {
// Round trip: a madmin reader decodes the latency values as Go
+7 -1
View File
@@ -369,8 +369,14 @@ pub fn allocator_reclaim_controller_snapshot(ctx: &CancellationToken) -> Allocat
}
#[cfg(not(target_os = "windows"))]
#[allow(unsafe_code)]
fn collect_allocator_memory(force: bool) -> Result<(), String> {
rustfs_mimalloc::MiMalloc::collect(force);
// SAFETY: `mi_collect` is provided by the active global allocator backend
// on this target family. It is explicitly intended to reclaim retained
// pages/segments and does not require additional invariants from the caller.
unsafe {
libmimalloc_sys::mi_collect(force);
}
Ok(())
}
+8 -10
View File
@@ -26,22 +26,22 @@ struct MiMallocAllocator;
unsafe impl GlobalAlloc for MiMallocAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// SAFETY: the caller upholds GlobalAlloc's contract for layout.
unsafe { rustfs_mimalloc::MiMalloc.alloc(layout) }
unsafe { mimalloc::MiMalloc.alloc(layout) }
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
// SAFETY: the caller upholds GlobalAlloc's contract for layout.
unsafe { rustfs_mimalloc::MiMalloc.alloc_zeroed(layout) }
unsafe { mimalloc::MiMalloc.alloc_zeroed(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// SAFETY: ptr and layout came from this allocator and are forwarded unchanged.
unsafe { rustfs_mimalloc::MiMalloc.dealloc(ptr, layout) }
unsafe { mimalloc::MiMalloc.dealloc(ptr, layout) }
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
// SAFETY: ptr and layout came from this allocator and are forwarded unchanged.
unsafe { rustfs_mimalloc::MiMalloc.realloc(ptr, layout, new_size) }
unsafe { mimalloc::MiMalloc.realloc(ptr, layout, new_size) }
}
}
@@ -51,7 +51,7 @@ static GLOBAL: hotpath::CountingAllocator<MiMallocAllocator> = hotpath::Counting
#[cfg(not(all(feature = "hotpath", feature = "hotpath-alloc")))]
#[global_allocator]
static GLOBAL: rustfs_mimalloc::MiMalloc = rustfs_mimalloc::MiMalloc;
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
fn main() {
let _hotpath_guard = hotpath::HotpathGuardBuilder::new("main").build();
@@ -71,9 +71,8 @@ mod tests {
allocation.extend_from_slice(&[7_u8; 64]);
assert_eq!(allocation.len(), 64);
let heap = rustfs_mimalloc::heap::Heap::main();
// SAFETY: the live Vec pointer is valid to inspect for heap ownership.
assert!(unsafe { heap.contains(allocation.as_ptr()) });
assert!(unsafe { libmimalloc_sys::mi_is_in_heap_region(allocation.as_ptr().cast()) });
}
#[test]
@@ -86,13 +85,12 @@ mod tests {
let layout = Layout::from_size_align(32, 8).expect("valid test allocation layout");
let grown_layout = Layout::from_size_align(64, 8).expect("valid grown test allocation layout");
let allocator = super::MiMallocAllocator;
let heap = rustfs_mimalloc::heap::Heap::main();
// SAFETY: The pointer is checked for null before use and later released
// through the same allocator with the corresponding layout.
let ptr = unsafe { allocator.alloc_zeroed(layout) };
assert!(!ptr.is_null());
assert!(unsafe { heap.contains(ptr) });
assert!(unsafe { libmimalloc_sys::mi_is_in_heap_region(ptr.cast()) });
assert!(unsafe { std::slice::from_raw_parts(ptr, 32).iter().all(|byte| *byte == 0) });
// SAFETY: `ptr` was allocated by `allocator` with `layout`; on failure
@@ -104,7 +102,7 @@ mod tests {
panic!("mimalloc realloc failed in allocator smoke test");
}
assert!(unsafe { heap.contains(grown_ptr) });
assert!(unsafe { libmimalloc_sys::mi_is_in_heap_region(grown_ptr.cast()) });
// SAFETY: `grown_ptr` was reallocated by `allocator` and is released
// with the matching grown layout.
unsafe { allocator.dealloc(grown_ptr, grown_layout) };
+36 -19
View File
@@ -17,7 +17,10 @@ use rustfs_io_metrics::{
record_cpu_usage, record_memory_usage, record_process_memory_split,
};
use serde::Serialize;
#[cfg(any(test, not(target_os = "windows")))]
use serde_json::Value;
#[cfg(not(target_os = "windows"))]
use std::ffi::CStr;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
@@ -228,18 +231,7 @@ fn read_cgroup_memory_snapshot() -> Option<CgroupMemorySnapshot> {
read_cgroup_v2().or_else(read_cgroup_v1)
}
fn read_allocator_memory_snapshot() -> Option<AllocatorMemorySnapshot> {
let json = rustfs_mimalloc::MiMalloc::stats_json();
if json.is_empty() {
return None;
}
let observation = parse_mimalloc_stats_json(&json)?;
Some(AllocatorMemorySnapshot {
backend: crate::allocator_reclaim::allocator_backend(),
observation,
})
}
#[cfg(any(test, not(target_os = "windows")))]
fn numeric_json_value(value: &Value) -> Option<u64> {
match value {
Value::Number(number) => number
@@ -250,6 +242,7 @@ fn numeric_json_value(value: &Value) -> Option<u64> {
}
}
#[cfg(any(test, not(target_os = "windows")))]
fn numeric_json_field(value: &Value, field: &str) -> Option<u64> {
match value {
Value::Object(fields) => fields
@@ -261,6 +254,7 @@ fn numeric_json_field(value: &Value, field: &str) -> Option<u64> {
}
}
#[cfg(any(test, not(target_os = "windows")))]
fn mimalloc_stat_field(value: &Value, metric: &str, field: &str) -> Option<u64> {
match value {
Value::Object(fields) => {
@@ -277,10 +271,12 @@ fn mimalloc_stat_field(value: &Value, metric: &str, field: &str) -> Option<u64>
}
}
#[cfg(any(test, not(target_os = "windows")))]
fn mimalloc_stat_current(value: &Value, metric: &str) -> Option<u64> {
mimalloc_stat_field(value, metric, "current")
}
#[cfg(any(test, not(target_os = "windows")))]
fn mimalloc_stat_sum(value: &Value, metrics: &[&str], field: &str) -> Option<u64> {
metrics
.iter()
@@ -289,6 +285,7 @@ fn mimalloc_stat_sum(value: &Value, metrics: &[&str], field: &str) -> Option<u64
.filter(|value| *value > 0)
}
#[cfg(any(test, not(target_os = "windows")))]
fn parse_mimalloc_stats_json(stats_json: &str) -> Option<AllocatorMemoryObservation> {
let value = serde_json::from_str::<Value>(stats_json).ok()?;
let malloc_metrics = ["malloc_normal", "malloc_huge"];
@@ -315,6 +312,33 @@ fn parse_mimalloc_stats_json(stats_json: &str) -> Option<AllocatorMemoryObservat
}
}
#[cfg(not(target_os = "windows"))]
#[allow(unsafe_code)]
fn read_allocator_memory_snapshot() -> Option<AllocatorMemorySnapshot> {
// SAFETY: `mi_stats_get_json` returns a null-terminated JSON buffer owned by
// mimalloc when called with a null input buffer. The mimalloc API requires
// freeing that buffer with `mi_free`; parsing finishes before the buffer is freed.
let observation = unsafe {
let stats_ptr = libmimalloc_sys::mi_stats_get_json(0, std::ptr::null_mut());
if stats_ptr.is_null() {
return None;
}
let observation = CStr::from_ptr(stats_ptr).to_str().ok().and_then(parse_mimalloc_stats_json);
libmimalloc_sys::mi_free(stats_ptr.cast());
observation?
};
Some(AllocatorMemorySnapshot {
backend: crate::allocator_reclaim::allocator_backend(),
observation,
})
}
#[cfg(target_os = "windows")]
fn read_allocator_memory_snapshot() -> Option<AllocatorMemorySnapshot> {
None
}
fn configured_memory_observability_interval_secs() -> u64 {
rustfs_utils::get_env_u64(ENV_MEMORY_OBSERVABILITY_INTERVAL_SECS, DEFAULT_MEMORY_OBSERVABILITY_INTERVAL_SECS).max(1)
}
@@ -542,13 +566,6 @@ mod tests {
assert_eq!(parse_mimalloc_stats_json(r#"{ "allocator": "unknown" }"#), None);
}
#[test]
fn read_allocator_memory_snapshot_uses_mimalloc_stats_json() {
let snapshot = super::read_allocator_memory_snapshot();
#[cfg(not(target_os = "windows"))]
assert!(snapshot.is_some(), "allocator snapshot should be available on non-Windows");
}
#[test]
fn memory_observability_snapshot_reports_disabled_when_metrics_are_disabled() {
let snapshot = build_memory_observability_status_snapshot(false, 15, false);