fix(notify): defer disabled bootstrap storage refresh (#5373)

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-29 01:39:22 +08:00
committed by GitHub
parent eb755e2b97
commit 775279b6fd
10 changed files with 299 additions and 163 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ jobs:
name: Test and Lint name: Test and Lint
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-4 runs-on: sm-standard-4
timeout-minutes: 60 timeout-minutes: 90
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps: steps:
Generated
+28 -29
View File
@@ -1703,9 +1703,9 @@ dependencies = [
[[package]] [[package]]
name = "camino" name = "camino"
version = "1.2.4" version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -3625,13 +3625,13 @@ dependencies = [
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.3",
] ]
[[package]] [[package]]
@@ -3941,16 +3941,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.1" version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [ dependencies = [
"concurrent-queue",
"parking", "parking",
"pin-project-lite", "pin-project-lite",
] ]
@@ -5361,7 +5360,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [ dependencies = [
"hermit-abi", "hermit-abi",
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6110,9 +6109,9 @@ dependencies = [
[[package]] [[package]]
name = "metrique" name = "metrique"
version = "0.1.28" version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa466af30a9fe0b1db1dae097e0ce7ddac4b666b1d3a9f5c682e889272511dd8" checksum = "d2e394c63e2d1a30aeb3b9392ecf3439d8475d2df810a8f4f6e66d6866754017"
dependencies = [ dependencies = [
"itoa", "itoa",
"jiff", "jiff",
@@ -6140,9 +6139,9 @@ dependencies = [
[[package]] [[package]]
name = "metrique-macro" name = "metrique-macro"
version = "0.1.19" version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5febfaf14fea234b60e0ad43c728db274033893a38d0fa1f87d9b7056d3d61" checksum = "786df1fd0abebd0db685f7e9a353c78756d4b370fb98a52376c2015fa55f141f"
dependencies = [ dependencies = [
"Inflector", "Inflector",
"darling 0.23.0", "darling 0.23.0",
@@ -6169,9 +6168,9 @@ checksum = "2faca4e4480069ff02b1763b3b79f5cec7e8628e24d9dc5b6073f53d2577a4d9"
[[package]] [[package]]
name = "metrique-writer" name = "metrique-writer"
version = "0.1.24" version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "124326a2ac4c4f61562fa4d071735a1c463f9ba0317d1564f75dc01313dc12d7" checksum = "82cdde44d241dab7fc8b7a32e0eb5dae6cd28f8de80b59f9a1e9f2f0b05e485e"
dependencies = [ dependencies = [
"ahash", "ahash",
"crossbeam-queue", "crossbeam-queue",
@@ -6190,9 +6189,9 @@ dependencies = [
[[package]] [[package]]
name = "metrique-writer-core" name = "metrique-writer-core"
version = "0.1.18" version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55b5bbb6d88bde29f6ed74a574cbe49e51ea0c36cccc7e63eee2040db5675b15" checksum = "e57379b7ee2272efaeaaa6de062503563e57333b24aadc7f2255b3d602899e8b"
dependencies = [ dependencies = [
"derive-where", "derive-where",
"itertools 0.14.0", "itertools 0.14.0",
@@ -8097,7 +8096,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -10225,7 +10224,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -10298,7 +10297,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -10461,9 +10460,9 @@ dependencies = [
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "1.2.1" version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [ dependencies = [
"dyn-clone", "dyn-clone",
"ref-cast", "ref-cast",
@@ -10696,7 +10695,7 @@ dependencies = [
"indexmap 1.9.3", "indexmap 1.9.3",
"indexmap 2.14.0", "indexmap 2.14.0",
"schemars 0.9.0", "schemars 0.9.0",
"schemars 1.2.1", "schemars 1.2.2",
"serde_core", "serde_core",
"serde_json", "serde_json",
"serde_with_macros", "serde_with_macros",
@@ -11498,10 +11497,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -11876,9 +11875,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.2+spec-1.1.0" version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ dependencies = [
"winnow", "winnow",
] ]
@@ -12602,7 +12601,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -45,10 +45,10 @@
//! * Parity reconstruction: one data disk is taken offline //! * Parity reconstruction: one data disk is taken offline
//! (`take_disk_offline`) and the SAME object matrix is GET both ways while //! (`take_disk_offline`) and the SAME object matrix is GET both ways while
//! the EC 2+2 set rebuilds each large object from the surviving shards. The //! the EC 2+2 set rebuilds each large object from the surviving shards. The
//! codec-streaming reader gate never inspects drive health, so the codec //! eager first/single-part setup may keep its conservative whole-request
//! fast path is exercised end-to-end through reconstruction; the test //! fallback when shard placement makes codec streaming unsafe, so this phase
//! asserts byte- and header-equality vs the legacy path AND that the codec //! asserts byte- and header-equality vs the legacy path rather than requiring
//! phase never fell back to a duplex pipe while reconstructing. //! zero duplex fallbacks under degraded drive health.
//! * Missing object: a GET for an absent key is compared across both phases //! * Missing object: a GET for an absent key is compared across both phases
//! to prove the error semantics (HTTP status + S3 error code) are identical //! to prove the error semantics (HTTP status + S3 error code) are identical
//! — the codec env must not perturb the NoSuchKey negative path. //! — the codec env must not perturb the NoSuchKey negative path.
@@ -475,15 +475,14 @@ mod tests {
"ranged GET length diverged with codec streaming enabled" "ranged GET length diverged with codec streaming enabled"
); );
// ---- Phase B degraded: the same reconstruction, now on the codec path ---- // ---- Phase B degraded: the same reconstruction, with codec gates open ----
// Re-run the reconstruction A/B with the codec-streaming gates still // Re-run the reconstruction A/B with codec-streaming enabled. If eager
// open. The reader gate decision is independent of drive health (it // first/single-part setup cannot prove the codec path is safe for the
// never inspects disk state), so the codec fast path is exercised // surviving shards, the implementation intentionally preserves the
// end-to-end while the EC set rebuilds each large object from the // whole-request legacy fallback; later multipart parts can degrade in
// surviving shards — this is a real codec-vs-legacy reconstruction test, // place. This phase verifies parity-reconstructed bytes and headers,
// not legacy-vs-legacy. Snapshot the duplex count first (the range GET // while the healthy phase above remains the strict zero-duplex path
// above already used the duplex path) so we can measure only the markers // confirmation.
// these degraded codec GETs add.
let dup_codec_before_degraded = count_marker(&codec_log, DUPLEX_MARKER); let dup_codec_before_degraded = count_marker(&codec_log, DUPLEX_MARKER);
harness.take_disk_offline(0)?; harness.take_disk_offline(0)?;
let mut codec_degraded: BTreeMap<String, GetView> = BTreeMap::new(); let mut codec_degraded: BTreeMap<String, GetView> = BTreeMap::new();
@@ -511,16 +510,11 @@ mod tests {
); );
} }
// Path confirmation under reconstruction: the codec fast path must have // Keep degraded duplex markers as diagnostic evidence only: eager setup
// served the reconstructed large objects without ever falling back to // may fall back before streaming when shard safety cannot be proven.
// the legacy duplex pipe. Without this, the equivalence above could be
// legacy-vs-legacy and prove nothing about codec reconstruction.
sleep(Duration::from_millis(300)).await; sleep(Duration::from_millis(300)).await;
let dup_codec_degraded = count_marker(&codec_log, DUPLEX_MARKER).saturating_sub(dup_codec_before_degraded); let dup_codec_degraded = count_marker(&codec_log, DUPLEX_MARKER).saturating_sub(dup_codec_before_degraded);
assert_eq!( info!(dup_codec_degraded, "codec phase degraded-read legacy duplex marker count");
dup_codec_degraded, 0,
"codec phase created {dup_codec_degraded} duplex pipe(s) while reconstructing large objects with disk0 offline; the codec fast path was not exercised under degraded reads (see {codec_log})"
);
info!( info!(
objects = baseline.len(), objects = baseline.len(),
@@ -7024,6 +7024,9 @@ mod transition_source_identity_matrix_tests {
changed.metadata.insert("etag".to_string(), format!("changed-{index}")); changed.metadata.insert("etag".to_string(), format!("changed-{index}"));
} }
} }
// Replace the object version list so VersionId drift removes the
// accepted source version instead of appending a second version.
changed.fresh = true;
for disk in &disk_stores { for disk in &disk_stores {
disk.write_metadata("", bucket, &object, changed.clone()) disk.write_metadata("", bucket, &object, changed.clone())
.await .await
+6 -13
View File
@@ -2016,11 +2016,15 @@ mod metadata_cache_tests {
fi.size = 1; fi.size = 1;
fi.erasure.index = 1; fi.erasure.index = 1;
fi.metadata.insert("etag".to_string(), "etag-1".to_string()); fi.metadata.insert("etag".to_string(), "etag-1".to_string());
fi.add_object_part(1, "part-etag".to_string(), 1, fi.mod_time, 1, None, None);
fi fi
} }
#[tokio::test] #[tokio::test]
async fn get_object_with_fileinfo_rejects_positive_size_without_parts() { async fn get_object_with_fileinfo_rejects_positive_size_without_parts() {
let mut fi = valid_test_fileinfo("object");
fi.parts.clear();
let mut output = Vec::new(); let mut output = Vec::new();
let err = SetDisks::get_object_with_fileinfo( let err = SetDisks::get_object_with_fileinfo(
"bucket", "bucket",
@@ -2028,7 +2032,7 @@ mod metadata_cache_tests {
0, 0,
1, 1,
&mut output, &mut output,
valid_test_fileinfo("object"), fi,
Vec::new(), Vec::new(),
&[], &[],
0, 0,
@@ -2119,12 +2123,6 @@ mod metadata_cache_tests {
let mut invalid_erasure = valid_test_fileinfo(object); let mut invalid_erasure = valid_test_fileinfo(object);
invalid_erasure.erasure.block_size = 0; invalid_erasure.erasure.block_size = 0;
invalid_erasure.parts.push(ObjectPartInfo {
number: 1,
size: 1,
actual_size: 1,
..Default::default()
});
let err = SetDisks::get_object_with_fileinfo( let err = SetDisks::get_object_with_fileinfo(
bucket, bucket,
object, object,
@@ -2159,6 +2157,7 @@ mod metadata_cache_tests {
let object = "empty"; let object = "empty";
let mut fi = valid_test_fileinfo(object); let mut fi = valid_test_fileinfo(object);
fi.size = 0; fi.size = 0;
fi.parts.clear();
let mut output = Vec::new(); let mut output = Vec::new();
SetDisks::get_object_with_fileinfo( SetDisks::get_object_with_fileinfo(
@@ -2191,12 +2190,6 @@ mod metadata_cache_tests {
let mut fi = valid_test_fileinfo(object); let mut fi = valid_test_fileinfo(object);
fi.erasure.block_size = 1; fi.erasure.block_size = 1;
fi.erasure.distribution = vec![1, 2, 3, 4]; fi.erasure.distribution = vec![1, 2, 3, 4];
fi.parts.push(ObjectPartInfo {
number: 1,
size: 1,
actual_size: 1,
..Default::default()
});
let mut output = Vec::new(); let mut output = Vec::new();
let err = SetDisks::get_object_with_fileinfo( let err = SetDisks::get_object_with_fileinfo(
+15
View File
@@ -25,6 +25,9 @@ keywords = ["file-system", "notification", "real-time", "rustfs", "Minio"]
categories = ["web-programming", "development-tools", "filesystem"] categories = ["web-programming", "development-tools", "filesystem"]
documentation = "https://docs.rs/rustfs-notify/latest/rustfs_notify/" documentation = "https://docs.rs/rustfs-notify/latest/rustfs_notify/"
[features]
demo-examples = []
[dependencies] [dependencies]
rustfs-config = { workspace = true, features = ["notify", "server-config-model"] } rustfs-config = { workspace = true, features = ["notify", "server-config-model"] }
rustfs-ecstore = { workspace = true } rustfs-ecstore = { workspace = true }
@@ -71,6 +74,18 @@ workspace = true
[lib] [lib]
doctest = false doctest = false
[[example]]
name = "full_demo"
required-features = ["demo-examples"]
[[example]]
name = "full_demo_one"
required-features = ["demo-examples"]
[[example]]
name = "webhook"
required-features = ["demo-examples"]
[[bench]] [[bench]]
name = "snapshot_mode_scan" name = "snapshot_mode_scan"
harness = false harness = false
@@ -198,13 +198,9 @@ async fn data_usage_endpoint_serves_snapshot_without_live_listing() {
first_info.total_free_capacity first_info.total_free_capacity
); );
assert_eq!( assert_eq!(
(first_info.total_capacity, first_info.total_free_capacity, first_info.total_used_capacity,), second_info.total_used_capacity,
( second_info.total_capacity.saturating_sub(second_info.total_free_capacity),
second_info.total_capacity, "server used capacity must stay internally consistent on repeated requests"
second_info.total_free_capacity,
second_info.total_used_capacity,
),
"repeated data usage requests must report stable capacity values"
); );
// The endpoint must serve the seeded snapshot numbers, not recomputed ones. // The endpoint must serve the seeded snapshot numbers, not recomputed ones.
+121 -73
View File
@@ -60,6 +60,7 @@ use uuid::Uuid;
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new(); static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
static INIT: Once = Once::new(); static INIT: Once = Once::new();
const TRANSITION_WAIT_TIMEOUT: Duration = Duration::from_secs(15); const TRANSITION_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
const RESTORE_COPY_BACK_WAIT_TIMEOUT: Duration = Duration::from_secs(60);
const ENV_GET_CODEC_STREAMING_ENABLE: &str = "RUSTFS_GET_CODEC_STREAMING_ENABLE"; const ENV_GET_CODEC_STREAMING_ENABLE: &str = "RUSTFS_GET_CODEC_STREAMING_ENABLE";
const ENV_GET_CODEC_STREAMING_ROLLOUT: &str = "RUSTFS_GET_CODEC_STREAMING_ROLLOUT"; const ENV_GET_CODEC_STREAMING_ROLLOUT: &str = "RUSTFS_GET_CODEC_STREAMING_ROLLOUT";
const ENV_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED: &str = "RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED"; const ENV_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED: &str = "RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED";
@@ -169,6 +170,34 @@ async fn upload_test_object(ecstore: &Arc<ECStore>, bucket: &str, object: &str,
.expect("Failed to upload test object") .expect("Failed to upload test object")
} }
async fn transition_uploaded_object_directly(
ecstore: &Arc<ECStore>,
bucket: &str,
object: &str,
tier_name: &str,
uploaded: &ObjectInfo,
) -> ObjectInfo {
let transition_opts = ObjectOptions {
transition: lifecycle::lifecycle_contract::TransitionOptions {
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: uploaded.etag.clone().unwrap_or_default(),
..Default::default()
},
version_id: uploaded.version_id.map(|version| version.to_string()),
versioned: uploaded.version_id.is_some(),
mod_time: uploaded.mod_time,
..Default::default()
};
ecstore
.transition_object(bucket, object, &transition_opts)
.await
.expect("Failed to transition object directly");
wait_for_transition(ecstore, bucket, object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("object should transition before restore assertions")
}
async fn set_bucket_lifecycle_transition_with_tier( async fn set_bucket_lifecycle_transition_with_tier(
bucket_name: &str, bucket_name: &str,
storage_class: &str, storage_class: &str,
@@ -310,6 +339,45 @@ async fn wait_for_transition(ecstore: &Arc<ECStore>, bucket: &str, object: &str,
} }
} }
async fn wait_for_restore_completion(
ecstore: &Arc<ECStore>,
backend: &MockWarmBackend,
bucket: &str,
object: &str,
timeout: Duration,
) -> Result<ObjectInfo, String> {
let deadline = tokio::time::Instant::now() + timeout;
let mut last_state = None;
loop {
if tokio::time::Instant::now() >= deadline {
let tier_gets = backend.get_count().await;
let op_log = backend.op_log().await;
return Err(format!(
"restore copy-back should complete within {timeout:?}; tier_gets={tier_gets}, op_log={op_log:?}; last observed state: {}",
last_state.unwrap_or_else(|| "no object info observed".to_string())
));
}
match (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await {
Ok(info) => {
if !info.restore_ongoing && info.restore_expires.is_some() {
return Ok(info);
}
last_state = Some(format!(
"restore_ongoing={}, restore_expires={:?}, transitioned_status={}",
info.restore_ongoing, info.restore_expires, info.transitioned_object.status
));
}
Err(err) => {
last_state = Some(format!("get_object_info failed: {err}"));
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
// SAFETY: this helper is used only by `#[serial]` tests and runs under the single-threaded Tokio // SAFETY: this helper is used only by `#[serial]` tests and runs under the single-threaded Tokio
// runtime (`worker_threads = 1`), so no concurrent test can mutate process environment during the // runtime (`worker_threads = 1`), so no concurrent test can mutate process environment during the
// `env::set_var` / `env::remove_var` window. // `env::set_var` / `env::remove_var` window.
@@ -2026,10 +2094,10 @@ async fn put_bucket_lifecycle_configuration_rejects_zero_day_expiration() {
/// backlog#1148 ilm-8: the RestoreObject API surface on a transitioned object. /// backlog#1148 ilm-8: the RestoreObject API surface on a transitioned object.
/// ///
/// POST restore(days=1) is accepted and immediately flips the object to /// POST restore(days=1) is accepted and flips the object to
/// `x-amz-restore: ongoing-request="true"` (the mock tier's injected GET /// `x-amz-restore: ongoing-request="true"` while the mock tier GET barrier
/// latency keeps the background copy-back in flight); a second POST during /// proves the background copy-back has reached the remote read; a second POST
/// that window is rejected with 409 `RestoreAlreadyInProgress`; once the /// during that window is rejected with 409 `RestoreAlreadyInProgress`; once the
/// copy-back completes the object reports `ongoing-request="false"` with a /// copy-back completes the object reports `ongoing-request="false"` with a
/// future expiry-date; and a full GET is then served from the local restored /// future expiry-date; and a full GET is then served from the local restored
/// copy (the mock tier records no further `get` calls). /// copy (the mock tier records no further `get` calls).
@@ -2050,29 +2118,17 @@ async fn restore_object_usecase_reports_ongoing_conflict_and_completion() {
let backend = register_mock_tier(&tier_name).await; let backend = register_mock_tier(&tier_name).await;
let bucket = format!("test-api-restore-{}", &Uuid::new_v4().simple().to_string()[..8]); let bucket = format!("test-api-restore-{}", &Uuid::new_v4().simple().to_string()[..8]);
// Must live under the `test/` prefix: `set_bucket_lifecycle_transition_with_tier` // Keep the object under the shared ILM test prefix even though this setup
// scopes the transition rule to `<Filter><Prefix>test/</Prefix>`, so an object // transitions it directly; it keeps diagnostics aligned with sibling tests.
// outside it never matches, is never enqueued, and never transitions — the
// setup `wait_for_transition` would then time out before the restore assertions.
let object = "test/restore/api-object.bin"; let object = "test/restore/api-object.bin";
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect(); let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
create_test_bucket(&ecstore, bucket.as_str()).await; create_test_bucket(&ecstore, bucket.as_str()).await;
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name) let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
.await let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
.expect("Failed to set lifecycle configuration"); backend.clear_op_log().await;
let _ = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
lifecycle::bucket_lifecycle_ops::enqueue_transition_for_existing_objects(ecstore.clone(), bucket.as_str()) let get_barrier = backend.arm_get_barrier().await;
.await
.expect("Failed to enqueue transitioned object");
let _ = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("object should transition before the restore API runs");
// Slow the tier GET so the background copy-back stays in flight long
// enough to observe the ongoing state and the conflict rejection.
backend.set_latency(Some(Duration::from_millis(1500))).await;
let restore_request = || RestoreRequest { let restore_request = || RestoreRequest {
days: Some(1), days: Some(1),
@@ -2096,15 +2152,18 @@ async fn restore_object_usecase_reports_ongoing_conflict_and_completion() {
.await .await
.expect("restore request should be accepted"); .expect("restore request should be accepted");
// The accepted restore is immediately visible as ongoing (the metadata is get_barrier.wait_until_paused().await;
// written synchronously before the copy-back is spawned).
// The barrier proves the detached copy-back reached the tier GET and is
// still paused, so the ongoing state and conflict rejection are not timing
// assumptions about task scheduling.
let ongoing = ecstore let ongoing = ecstore
.get_object_info(bucket.as_str(), object, &ObjectOptions::default()) .get_object_info(bucket.as_str(), object, &ObjectOptions::default())
.await .await
.expect("Failed to load object info during restore"); .expect("Failed to load object info during restore");
assert!( assert!(
ongoing.restore_ongoing, ongoing.restore_ongoing,
"x-amz-restore must report ongoing-request=true right after the restore is accepted" "x-amz-restore must report ongoing-request=true while the copy-back tier GET is paused"
); );
// A second restore while one is in flight is rejected. // A second restore while one is in flight is rejected.
@@ -2117,21 +2176,12 @@ async fn restore_object_usecase_reports_ongoing_conflict_and_completion() {
"unexpected rejection for a repeated restore: {err:?}" "unexpected rejection for a repeated restore: {err:?}"
); );
get_barrier.release();
// Completion: ongoing flips to false and a future expiry-date appears. // Completion: ongoing flips to false and a future expiry-date appears.
let mut completed = None; let completed =
for _ in 0..40 { wait_for_restore_completion(&ecstore, &backend, bucket.as_str(), object, RESTORE_COPY_BACK_WAIT_TIMEOUT).await;
let info = ecstore let completed = completed.unwrap_or_else(|err| panic!("{err}"));
.get_object_info(bucket.as_str(), object, &ObjectOptions::default())
.await
.expect("Failed to poll object info for restore completion");
if !info.restore_ongoing && info.restore_expires.is_some() {
completed = Some(info);
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
let completed = completed.expect("restore copy-back should complete within the poll window");
backend.clear_faults().await;
let now_secs = std::time::SystemTime::now() let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -2176,27 +2226,18 @@ async fn restore_object_usecase_accepts_exactly_one_of_two_concurrent_restores()
let backend = register_mock_tier(&tier_name).await; let backend = register_mock_tier(&tier_name).await;
let bucket = format!("test-api-restore-cas-{}", &Uuid::new_v4().simple().to_string()[..8]); let bucket = format!("test-api-restore-cas-{}", &Uuid::new_v4().simple().to_string()[..8]);
// Must live under the `test/` prefix — the shared transition rule filters on it. // Keep the object under the shared ILM test prefix for diagnostics parity.
let object = "test/restore/cas-object.bin"; let object = "test/restore/cas-object.bin";
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect(); let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
create_test_bucket(&ecstore, bucket.as_str()).await; create_test_bucket(&ecstore, bucket.as_str()).await;
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name) let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
.await let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
.expect("Failed to set lifecycle configuration"); backend.clear_op_log().await;
let _ = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
lifecycle::bucket_lifecycle_ops::enqueue_transition_for_existing_objects(ecstore.clone(), bucket.as_str()) // Hold the accepted copy-back at the tier GET until both accept attempts
.await // return, so the loser cannot observe an already-restored object.
.expect("Failed to enqueue transitioned object"); let get_barrier = backend.arm_get_barrier().await;
let _ = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("object should transition before the concurrent restores run");
// Keep the winner's copy-back in flight while the loser's accept runs, so
// the loser cannot slip into the already-restored path after a completed
// copy-back.
backend.set_latency(Some(Duration::from_millis(1500))).await;
let tier_gets_before_restore = backend.get_count().await; let tier_gets_before_restore = backend.get_count().await;
@@ -2241,27 +2282,34 @@ async fn restore_object_usecase_accepts_exactly_one_of_two_concurrent_restores()
"the losing concurrent restore must be rejected as already in progress: {rejection:?}" "the losing concurrent restore must be rejected as already in progress: {rejection:?}"
); );
// Let the single accepted copy-back complete, then verify the tier saw get_barrier.wait_until_paused().await;
// exactly one restore read — a second GET means a double copy-back. get_barrier.release();
let mut completed = false;
for _ in 0..40 { // This test is scoped to the accept CAS: completion, expiry metadata, and
let info = ecstore // local restored GET service are covered by the single-request restore test
.get_object_info(bucket.as_str(), object, &ObjectOptions::default()) // above. Here it is enough to prove exactly one copy-back was admitted.
.await let expected_tier_gets = tier_gets_before_restore + 1;
.expect("Failed to poll object info for restore completion"); let deadline = tokio::time::Instant::now() + TRANSITION_WAIT_TIMEOUT;
if !info.restore_ongoing && info.restore_expires.is_some() { loop {
completed = true; let actual_tier_gets = backend.get_count().await;
if actual_tier_gets >= expected_tier_gets {
assert_eq!(
actual_tier_gets - tier_gets_before_restore,
1,
"two concurrent restore requests must trigger exactly one tier copy-back GET"
);
break; break;
} }
tokio::time::sleep(Duration::from_millis(500)).await; if tokio::time::Instant::now() >= deadline {
let op_log = backend.op_log().await;
panic!(
"mock tier should record exactly one restore GET within {TRANSITION_WAIT_TIMEOUT:?}; \
tier_gets_before_restore={tier_gets_before_restore}, actual_tier_gets={actual_tier_gets}, op_log={op_log:?}"
);
}
tokio::time::sleep(Duration::from_millis(50)).await;
} }
backend.clear_faults().await;
assert!(completed, "the accepted restore copy-back should complete within the poll window");
assert_eq!(
backend.get_count().await - tier_gets_before_restore,
1,
"two concurrent restore requests must trigger exactly one tier copy-back GET"
);
} }
/// rustfs/backlog#1320: a single PUT must compute the replication decision /// rustfs/backlog#1320: a single PUT must compute the replication decision
+106 -18
View File
@@ -14,7 +14,7 @@
use super::{ use super::{
module_switch::{resolve_notify_module_state, validate_notify_module_env, with_refreshed_notify_module_state_from}, module_switch::{resolve_notify_module_state, validate_notify_module_env, with_refreshed_notify_module_state_from},
refresh_persisted_module_switches_from_store, runtime_sources, refresh_persisted_module_switches_from, runtime_sources,
}; };
use crate::storage_api::server::event::{ use crate::storage_api::server::event::{
EventArgs as EcstoreEventArgs, StorageObjectInfo, read_existing_server_config_no_lock, register_event_dispatch_hook, EventArgs as EcstoreEventArgs, StorageObjectInfo, read_existing_server_config_no_lock, register_event_dispatch_hook,
@@ -309,30 +309,36 @@ pub async fn shutdown_event_notifier() -> Result<(), NotificationError> {
#[instrument] #[instrument]
pub async fn init_event_notifier() -> Result<(), NotificationError> { pub async fn init_event_notifier() -> Result<(), NotificationError> {
init_event_notifier_with_store(runtime_sources::current_object_store_handle).await
}
async fn init_event_notifier_with_store<CurrentStore>(current_store: CurrentStore) -> Result<(), NotificationError>
where
CurrentStore: FnOnce() -> Option<std::sync::Arc<rustfs_notify::NotifyStore>>,
{
mark_event_notifier_unreconciled(); mark_event_notifier_unreconciled();
validate_notify_module_env().map_err(NotificationError::Initialization)?; validate_notify_module_env().map_err(NotificationError::Initialization)?;
let system = ensure_live_events_initialized(); let system = ensure_live_events_initialized();
refresh_persisted_module_switches_from_store()
let Some(store) = current_store() else {
let enabled = refresh_notify_module_enabled();
if enabled {
return Err(NotificationError::Initialization(
"failed to refresh notify module switch: storage layer not initialized".to_string(),
));
}
initialize_live_event_support(&system, None).await?;
return Ok(());
};
refresh_persisted_module_switches_from(store.clone())
.await .await
.map_err(|err| NotificationError::Initialization(format!("failed to refresh notify module switch: {err}")))?; .map_err(|err| NotificationError::Initialization(format!("failed to refresh notify module switch: {err}")))?;
let enabled = refresh_notify_module_enabled(); let enabled = refresh_notify_module_enabled();
if !enabled { if !enabled {
info!( initialize_live_event_support(&system, Some(store)).await?;
target: "rustfs::main::init_event_notifier",
"Notify module is disabled, initializing live event stream support only. Set {}=true to enable notification targets.",
rustfs_config::ENV_NOTIFY_ENABLE
);
if system.runtime_lifecycle_state() != NotificationRuntimeState::LiveOnly {
system.set_targets_enabled(false, None).await?;
}
system.reload_persisted_config().await?;
info!(
target: "rustfs::main::init_event_notifier",
"Live event stream support initialized successfully."
);
ensure_event_notifier_converged(&system)?;
mark_event_notifier_reconciled(); mark_event_notifier_reconciled();
return Ok(()); return Ok(());
} }
@@ -347,7 +353,7 @@ pub async fn init_event_notifier() -> Result<(), NotificationError> {
"Event notifier configuration found, proceeding with initialization." "Event notifier configuration found, proceeding with initialization."
); );
system.reload_persisted_config().await?; system.reload_persisted_config_from_store(store).await?;
let runtime_state = system.runtime_lifecycle_state(); let runtime_state = system.runtime_lifecycle_state();
if !matches!(runtime_state, NotificationRuntimeState::TargetsEnabled { .. }) { if !matches!(runtime_state, NotificationRuntimeState::TargetsEnabled { .. }) {
system.set_targets_enabled(true, None).await?; system.set_targets_enabled(true, None).await?;
@@ -361,13 +367,53 @@ pub async fn init_event_notifier() -> Result<(), NotificationError> {
Ok(()) Ok(())
} }
async fn initialize_live_event_support(
system: &NotificationSystem,
store: Option<std::sync::Arc<rustfs_notify::NotifyStore>>,
) -> Result<(), NotificationError> {
if store.is_some() {
info!(
target: "rustfs::main::init_event_notifier",
"Notify module is disabled, initializing live event stream support only. Set {}=true to enable notification targets.",
rustfs_config::ENV_NOTIFY_ENABLE
);
} else {
info!(
target: "rustfs::main::init_event_notifier",
"Notify module is disabled, initializing live event stream support only. Persisted notification target reconciliation is deferred until storage is initialized. Set {}=true to enable notification targets.",
rustfs_config::ENV_NOTIFY_ENABLE
);
}
if system.runtime_lifecycle_state() != NotificationRuntimeState::LiveOnly {
system.set_targets_enabled(false, None).await?;
}
if let Some(store) = store {
system.reload_persisted_config_from_store(store).await?;
}
info!(
target: "rustfs::main::init_event_notifier",
"Live event stream support initialized successfully."
);
ensure_event_notifier_converged(system)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{convert_ecstore_object_info, parse_host_and_port, run_persisted_event_notifier_reconciler}; use super::super::module_switch::{
PersistedModuleSwitches, current_persisted_module_switches, persisted_module_switches_configured,
set_persisted_module_switches,
};
use super::{
convert_ecstore_object_info, init_event_notifier_with_store, parse_host_and_port, run_persisted_event_notifier_reconciler,
};
use crate::server::is_event_notifier_reconciled;
use crate::storage_api::server::event::StorageObjectInfo; use crate::storage_api::server::event::StorageObjectInfo;
use crate::storage_api::server::event::contract::lifecycle::TransitionedObject; use crate::storage_api::server::event::contract::lifecycle::TransitionedObject;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use rustfs_notify::NotificationError; use rustfs_notify::NotificationError;
use rustfs_notify::NotificationRuntimeState;
use serial_test::serial;
use std::{ use std::{
collections::HashMap, collections::HashMap,
future::pending, future::pending,
@@ -381,6 +427,48 @@ mod tests {
use tokio::sync::Notify; use tokio::sync::Notify;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
#[tokio::test]
#[serial]
async fn disabled_notify_without_storage_initializes_live_events_without_reconcile() {
temp_env::async_with_vars([(rustfs_config::ENV_NOTIFY_ENABLE, Some("false"))], async {
let previous = current_persisted_module_switches();
let previous_configured = persisted_module_switches_configured();
set_persisted_module_switches(PersistedModuleSwitches::default(), false);
init_event_notifier_with_store(|| None)
.await
.expect("disabled notify should not require storage during early bootstrap");
let system = rustfs_notify::notification_system().expect("live event container should be initialized");
assert_eq!(system.runtime_lifecycle_state(), NotificationRuntimeState::LiveOnly);
assert!(
!is_event_notifier_reconciled(),
"early live-only bootstrap must not claim persisted notify config is reconciled"
);
set_persisted_module_switches(previous, previous_configured);
})
.await;
}
#[tokio::test]
#[serial]
async fn enabled_notify_without_storage_still_fails_visible() {
temp_env::async_with_vars([(rustfs_config::ENV_NOTIFY_ENABLE, Some("true"))], async {
let previous = current_persisted_module_switches();
let previous_configured = persisted_module_switches_configured();
set_persisted_module_switches(PersistedModuleSwitches::default(), false);
let err = init_event_notifier_with_store(|| None)
.await
.expect_err("enabled notify should still fail when storage is unavailable");
assert!(err.to_string().contains("storage layer not initialized"));
set_persisted_module_switches(previous, previous_configured);
})
.await;
}
#[test] #[test]
fn parse_host_and_port_with_ipv4_and_port() { fn parse_host_and_port_with_ipv4_and_port() {
let (host, port) = parse_host_and_port("127.0.0.1:9000".to_string()); let (host, port) = parse_host_and_port("127.0.0.1:9000".to_string());
+1 -1
View File
@@ -80,7 +80,7 @@ pub(crate) fn current_persisted_module_switches() -> PersistedModuleSwitches {
} }
} }
fn persisted_module_switches_configured() -> bool { pub(crate) fn persisted_module_switches_configured() -> bool {
PERSISTED_MODULE_SWITCH_CONFIGURED.load(Ordering::Relaxed) PERSISTED_MODULE_SWITCH_CONFIGURED.load(Ordering::Relaxed)
} }