test(ecstore): rendezvous concurrent resend commits

This commit is contained in:
马登山
2026-07-29 11:28:06 +08:00
parent 2423ba8e3f
commit a02ca95093
2 changed files with 58 additions and 24 deletions
@@ -11306,23 +11306,25 @@ mod tests {
// Distinct payloads with distinct sizes: a mixed-generation reassembly // Distinct payloads with distinct sizes: a mixed-generation reassembly
// would produce bytes matching none of them (or fail the read outright). // would produce bytes matching none of them (or fail the read outright).
let candidates: Vec<Vec<u8>> = (0..3) let candidates: Vec<Vec<u8>> = (0..2)
.map(|g| { .map(|g| {
let len = 4096 + g * 512; let len = 4096 + g * 512;
vec![b'a' + g as u8; len] vec![b'a' + g as u8; len]
}) })
.collect(); .collect();
let commit_barrier = MultipartCommitBarrier::install(&bucket, object, MultipartCommitPause::PutPartBeforeLockLost); let commit_barrier = MultipartCommitBarrier::install_for_arrivals(
let start = Arc::new(tokio::sync::Barrier::new(candidates.len() + 1)); &bucket,
object,
MultipartCommitPause::PutPartBeforeLockAcquire,
candidates.len(),
);
let mut tasks = tokio::task::JoinSet::new(); let mut tasks = tokio::task::JoinSet::new();
for payload in candidates.iter().cloned() { for payload in candidates.iter().cloned() {
let store = ecstore.clone(); let store = ecstore.clone();
let bucket = bucket.clone(); let bucket = bucket.clone();
let upload_id = upload.upload_id.clone(); let upload_id = upload.upload_id.clone();
let start = Arc::clone(&start);
tasks.spawn(async move { tasks.spawn(async move {
start.wait().await;
let mut data = PutObjReader::from_vec(payload.clone()); let mut data = PutObjReader::from_vec(payload.clone());
store store
.put_object_part(&bucket, object, &upload_id, 1, &mut data, &ObjectOptions::default()) .put_object_part(&bucket, object, &upload_id, 1, &mut data, &ObjectOptions::default())
@@ -11330,11 +11332,10 @@ mod tests {
.map(|info| (info, payload)) .map(|info| (info, payload))
}); });
} }
start.wait().await;
// The first writer holds the uploadId commit lock while the other // Both writers finish streaming before racing for the uploadId commit
// resends reach the same critical section. Releasing it proves the // lock. Two generations are sufficient to exercise the mixed-shard
// handoff without depending on saturated CI disk latency. // hazard, while each waiter sits behind at most one cross-disk rename.
commit_barrier.wait_until_paused().await; commit_barrier.wait_until_paused().await;
commit_barrier.release(); commit_barrier.release();
+48 -15
View File
@@ -27,7 +27,7 @@ use crate::multipart_listing::paginate_multipart_listing;
use futures::{StreamExt, stream}; use futures::{StreamExt, stream};
use std::future::Future; use std::future::Future;
#[cfg(test)] #[cfg(test)]
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::task::JoinSet; use tokio::task::JoinSet;
@@ -36,6 +36,7 @@ const MULTIPART_LIST_IO_CONCURRENCY: usize = 16;
#[cfg(test)] #[cfg(test)]
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MultipartCommitPause { pub(crate) enum MultipartCommitPause {
PutPartBeforeLockAcquire,
PutPartBeforeLockLost, PutPartBeforeLockLost,
PutPartAfterRename, PutPartAfterRename,
BeforeLockLost, BeforeLockLost,
@@ -47,9 +48,10 @@ struct MultipartCommitBarrierState {
bucket: String, bucket: String,
object: String, object: String,
pause: MultipartCommitPause, pause: MultipartCommitPause,
armed: AtomicBool, expected_arrivals: usize,
arrivals: AtomicUsize,
arrived: tokio::sync::Notify, arrived: tokio::sync::Notify,
release: tokio::sync::Notify, release: tokio::sync::Semaphore,
} }
#[cfg(test)] #[cfg(test)]
@@ -64,13 +66,24 @@ static MULTIPART_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc
#[cfg(test)] #[cfg(test)]
impl MultipartCommitBarrier { impl MultipartCommitBarrier {
pub(crate) fn install(bucket: &str, object: &str, pause: MultipartCommitPause) -> Self { pub(crate) fn install(bucket: &str, object: &str, pause: MultipartCommitPause) -> Self {
Self::install_for_arrivals(bucket, object, pause, 1)
}
pub(crate) fn install_for_arrivals(
bucket: &str,
object: &str,
pause: MultipartCommitPause,
expected_arrivals: usize,
) -> Self {
assert!(expected_arrivals > 0, "multipart commit barrier must wait for at least one arrival");
let state = Arc::new(MultipartCommitBarrierState { let state = Arc::new(MultipartCommitBarrierState {
bucket: bucket.to_string(), bucket: bucket.to_string(),
object: object.to_string(), object: object.to_string(),
pause, pause,
armed: AtomicBool::new(true), expected_arrivals,
arrivals: AtomicUsize::new(0),
arrived: tokio::sync::Notify::new(), arrived: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(), release: tokio::sync::Semaphore::new(0),
}); });
let mut slot = MULTIPART_COMMIT_BARRIER let mut slot = MULTIPART_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None)) .get_or_init(|| std::sync::Mutex::new(None))
@@ -83,20 +96,28 @@ impl MultipartCommitBarrier {
} }
pub(crate) async fn wait_until_paused(&self) { pub(crate) async fn wait_until_paused(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified()) tokio::time::timeout(Duration::from_secs(30), async {
.await loop {
.expect("multipart completion should reach the deterministic commit barrier"); let arrived = self.state.arrived.notified();
if self.state.arrivals.load(Ordering::Acquire) >= self.state.expected_arrivals {
return;
}
arrived.await;
}
})
.await
.expect("multipart completion should reach the deterministic commit barrier");
} }
pub(crate) fn release(&self) { pub(crate) fn release(&self) {
self.state.release.notify_one(); self.state.release.add_permits(self.state.expected_arrivals);
} }
} }
#[cfg(test)] #[cfg(test)]
impl Drop for MultipartCommitBarrier { impl Drop for MultipartCommitBarrier {
fn drop(&mut self) { fn drop(&mut self) {
self.state.release.notify_one(); self.release();
let mut slot = MULTIPART_COMMIT_BARRIER let mut slot = MULTIPART_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None)) .get_or_init(|| std::sync::Mutex::new(None))
.lock() .lock()
@@ -116,11 +137,21 @@ async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartComm
.as_ref() .as_ref()
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && barrier.pause == pause) .filter(|barrier| barrier.bucket == bucket && barrier.object == object && barrier.pause == pause)
.cloned(); .cloned();
if let Some(barrier) = barrier if let Some(barrier) = barrier {
&& barrier.armed.swap(false, Ordering::AcqRel) if let Ok(previous) = barrier.arrivals.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
{ (current < barrier.expected_arrivals).then_some(current + 1)
barrier.arrived.notify_one(); }) {
barrier.release.notified().await; let arrival = previous + 1;
if arrival == barrier.expected_arrivals {
barrier.arrived.notify_one();
}
barrier
.release
.acquire()
.await
.expect("multipart commit barrier should remain open")
.forget();
}
} }
} }
@@ -693,6 +724,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
let part_path = format!("{}/{}/{}", upload_id_path, fi.data_dir.unwrap_or_default(), part_suffix); let part_path = format!("{}/{}/{}", upload_id_path, fi.data_dir.unwrap_or_default(), part_suffix);
#[cfg(test)]
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockAcquire).await;
// Serialize only the commit (rename_part), not the whole upload. Each // Serialize only the commit (rename_part), not the whole upload. Each
// concurrent stream writes to its own unique temp dir (see `tmp_part` // concurrent stream writes to its own unique temp dir (see `tmp_part`
// above), so the encode/stream phase never conflicts and must stay // above), so the encode/stream phase never conflicts and must stay