mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-26 05:56:50 +00:00
fix(ecstore): classify listing consumer disconnect as non-error completion (#4981)
This commit is contained in:
@@ -142,6 +142,13 @@ pub fn max_keys_plus_one(max_keys: i32, add_one: bool) -> i32 {
|
|||||||
enum GatherResultsState {
|
enum GatherResultsState {
|
||||||
LimitReached,
|
LimitReached,
|
||||||
InputClosed,
|
InputClosed,
|
||||||
|
/// The results receiver was dropped before the single page was delivered.
|
||||||
|
///
|
||||||
|
/// With a capacity-1 results channel and at most one send per
|
||||||
|
/// `gather_results` call, a send failure is equivalent to the consumer
|
||||||
|
/// (the `list_path` wrapper future) having been dropped — e.g. the client
|
||||||
|
/// disconnected mid-listing. This is a benign completion, not an error.
|
||||||
|
ConsumerGone,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -4026,6 +4033,11 @@ impl ECStore {
|
|||||||
match gather_results(cancel_rx2, opts, recv, result_tx).await {
|
match gather_results(cancel_rx2, opts, recv, result_tx).await {
|
||||||
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
|
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
|
||||||
Ok(GatherResultsState::InputClosed) => {}
|
Ok(GatherResultsState::InputClosed) => {}
|
||||||
|
// Consumer disconnect (e.g. client cancelled the request)
|
||||||
|
// is a benign completion: no error log, no err_tx send.
|
||||||
|
// The explicit cancel is idempotent and avoids relying on
|
||||||
|
// the wrapper's drop-guard ordering to stop the producer.
|
||||||
|
Ok(GatherResultsState::ConsumerGone) => cancel.cancel(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log_list_path_worker_error("store", "gather_results", &job2_context, &err);
|
log_list_path_worker_error("store", "gather_results", &job2_context, &err);
|
||||||
let _ = err_tx2.send(Arc::new(err));
|
let _ = err_tx2.send(Arc::new(err));
|
||||||
@@ -4683,7 +4695,10 @@ async fn gather_results(
|
|||||||
"list_objects gather_results reached page limit and cancelled upstream listing"
|
"list_objects gather_results reached page limit and cancelled upstream listing"
|
||||||
);
|
);
|
||||||
|
|
||||||
results_tx
|
// Invariant: the results channel has capacity 1 and this function
|
||||||
|
// performs at most one send per call, so a send failure here means
|
||||||
|
// the receiver was dropped (consumer gone), never a full buffer.
|
||||||
|
if results_tx
|
||||||
.send(MetaCacheEntriesSortedResult {
|
.send(MetaCacheEntriesSortedResult {
|
||||||
entries: Some(MetaCacheEntriesSorted {
|
entries: Some(MetaCacheEntriesSorted {
|
||||||
o: MetaCacheEntries(entries),
|
o: MetaCacheEntries(entries),
|
||||||
@@ -4692,7 +4707,15 @@ async fn gather_results(
|
|||||||
err: None,
|
err: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(Error::other)?;
|
.is_err()
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
bucket = %opts.bucket,
|
||||||
|
prefix = %opts.prefix,
|
||||||
|
"list_objects gather_results consumer disconnected before limited page delivery"
|
||||||
|
);
|
||||||
|
return Ok(GatherResultsState::ConsumerGone);
|
||||||
|
}
|
||||||
return Ok(GatherResultsState::LimitReached);
|
return Ok(GatherResultsState::LimitReached);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4724,7 +4747,10 @@ async fn gather_results(
|
|||||||
"list_objects gather_results drained all candidates without hitting limit"
|
"list_objects gather_results drained all candidates without hitting limit"
|
||||||
);
|
);
|
||||||
|
|
||||||
results_tx
|
// Invariant: the results channel has capacity 1 and this function performs
|
||||||
|
// at most one send per call, so a send failure here means the receiver was
|
||||||
|
// dropped (consumer gone), never a full buffer.
|
||||||
|
if results_tx
|
||||||
.send(MetaCacheEntriesSortedResult {
|
.send(MetaCacheEntriesSortedResult {
|
||||||
entries: Some(MetaCacheEntriesSorted {
|
entries: Some(MetaCacheEntriesSorted {
|
||||||
o: MetaCacheEntries(entries),
|
o: MetaCacheEntries(entries),
|
||||||
@@ -4733,7 +4759,15 @@ async fn gather_results(
|
|||||||
err: Some(Error::Unexpected.into()),
|
err: Some(Error::Unexpected.into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(Error::other)?;
|
.is_err()
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
bucket = %opts.bucket,
|
||||||
|
prefix = %opts.prefix,
|
||||||
|
"list_objects gather_results consumer disconnected before eof page delivery"
|
||||||
|
);
|
||||||
|
return Ok(GatherResultsState::ConsumerGone);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(GatherResultsState::InputClosed)
|
Ok(GatherResultsState::InputClosed)
|
||||||
}
|
}
|
||||||
@@ -5264,6 +5298,11 @@ impl Sets {
|
|||||||
match gather_results(cancel_rx2, opts, recv, result_tx).await {
|
match gather_results(cancel_rx2, opts, recv, result_tx).await {
|
||||||
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
|
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
|
||||||
Ok(GatherResultsState::InputClosed) => {}
|
Ok(GatherResultsState::InputClosed) => {}
|
||||||
|
// Consumer disconnect (e.g. client cancelled the request)
|
||||||
|
// is a benign completion: no error log, no err_tx send.
|
||||||
|
// The explicit cancel is idempotent and avoids relying on
|
||||||
|
// the wrapper's drop-guard ordering to stop the producer.
|
||||||
|
Ok(GatherResultsState::ConsumerGone) => cancel.cancel(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log_list_path_worker_error("sets", "gather_results", &job2_context, &err);
|
log_list_path_worker_error("sets", "gather_results", &job2_context, &err);
|
||||||
let _ = err_tx2.send(Arc::new(err));
|
let _ = err_tx2.send(Arc::new(err));
|
||||||
@@ -6219,6 +6258,11 @@ impl SetDisks {
|
|||||||
match gather_results(cancel_rx2, opts, recv, result_tx).await {
|
match gather_results(cancel_rx2, opts, recv, result_tx).await {
|
||||||
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
|
Ok(GatherResultsState::LimitReached) => cancel.cancel(),
|
||||||
Ok(GatherResultsState::InputClosed) => {}
|
Ok(GatherResultsState::InputClosed) => {}
|
||||||
|
// Consumer disconnect (e.g. client cancelled the request)
|
||||||
|
// is a benign completion: no error log, no err_tx send.
|
||||||
|
// The explicit cancel is idempotent and avoids relying on
|
||||||
|
// the wrapper's drop-guard ordering to stop the producer.
|
||||||
|
Ok(GatherResultsState::ConsumerGone) => cancel.cancel(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log_list_path_worker_error("set_disks", "gather_results", &job2_context, &err);
|
log_list_path_worker_error("set_disks", "gather_results", &job2_context, &err);
|
||||||
let _ = err_tx2.send(Arc::new(err));
|
let _ = err_tx2.send(Arc::new(err));
|
||||||
@@ -6638,6 +6682,51 @@ mod test {
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct CapturedLogs {
|
||||||
|
buffer: Arc<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CapturedLogWriter {
|
||||||
|
buffer: Arc<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapturedLogs {
|
||||||
|
fn contents(&self) -> String {
|
||||||
|
let buffer = self
|
||||||
|
.buffer
|
||||||
|
.lock()
|
||||||
|
.expect("captured logs mutex should not be poisoned")
|
||||||
|
.clone();
|
||||||
|
String::from_utf8(buffer).expect("captured logs should be valid UTF-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::io::Write for CapturedLogWriter {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.buffer
|
||||||
|
.lock()
|
||||||
|
.expect("captured logs mutex should not be poisoned")
|
||||||
|
.extend_from_slice(buf);
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> MakeWriter<'a> for CapturedLogs {
|
||||||
|
type Writer = CapturedLogWriter;
|
||||||
|
|
||||||
|
fn make_writer(&'a self) -> Self::Writer {
|
||||||
|
CapturedLogWriter {
|
||||||
|
buffer: Arc::clone(&self.buffer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
struct TestIndexGeneration {
|
struct TestIndexGeneration {
|
||||||
@@ -7201,6 +7290,132 @@ mod test {
|
|||||||
assert!(!cancel.is_cancelled());
|
assert!(!cancel.is_cancelled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_path_gather_results_reports_consumer_gone_when_receiver_dropped_at_limit() {
|
||||||
|
let (entry_tx, entry_rx) = mpsc::channel(4);
|
||||||
|
let (result_tx, result_rx) = mpsc::channel(1);
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
|
||||||
|
entry_tx
|
||||||
|
.send(test_meta_entry("obj-a"))
|
||||||
|
.await
|
||||||
|
.expect("test entry should be queued");
|
||||||
|
drop(result_rx);
|
||||||
|
|
||||||
|
let state = timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
gather_results(
|
||||||
|
cancel.clone(),
|
||||||
|
ListPathOptions {
|
||||||
|
bucket: "bucket".to_owned(),
|
||||||
|
limit: 1,
|
||||||
|
incl_deleted: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
entry_rx,
|
||||||
|
result_tx,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("gather_results should finish promptly when the consumer is gone")
|
||||||
|
.expect("consumer disconnect must be a benign completion, not an error");
|
||||||
|
|
||||||
|
assert_eq!(state, GatherResultsState::ConsumerGone);
|
||||||
|
// The limit branch cancels upstream before attempting the send.
|
||||||
|
assert!(cancel.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_path_gather_results_reports_consumer_gone_when_receiver_dropped_at_eof() {
|
||||||
|
let (entry_tx, entry_rx) = mpsc::channel(4);
|
||||||
|
let (result_tx, result_rx) = mpsc::channel(1);
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
|
||||||
|
entry_tx
|
||||||
|
.send(test_meta_entry("obj-a"))
|
||||||
|
.await
|
||||||
|
.expect("test entry should be queued");
|
||||||
|
drop(entry_tx);
|
||||||
|
drop(result_rx);
|
||||||
|
|
||||||
|
let state = timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
gather_results(
|
||||||
|
cancel.clone(),
|
||||||
|
ListPathOptions {
|
||||||
|
bucket: "bucket".to_owned(),
|
||||||
|
limit: 8,
|
||||||
|
incl_deleted: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
entry_rx,
|
||||||
|
result_tx,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("gather_results should finish promptly when the consumer is gone")
|
||||||
|
.expect("consumer disconnect must be a benign completion, not an error");
|
||||||
|
|
||||||
|
assert_eq!(state, GatherResultsState::ConsumerGone);
|
||||||
|
// The eof branch never cancels; the wrapper's ConsumerGone arm does.
|
||||||
|
assert!(!cancel.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The subscriber installed via `set_default` is thread-local, so this test
|
||||||
|
// must run on the current-thread runtime; a multi-thread runtime would make
|
||||||
|
// the log-absence assertion vacuous.
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn list_path_gather_results_consumer_gone_emits_no_worker_failure_log() {
|
||||||
|
let logs = CapturedLogs::default();
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.with_writer(logs.clone())
|
||||||
|
.with_ansi(false)
|
||||||
|
.without_time()
|
||||||
|
.finish();
|
||||||
|
let _guard = tracing::subscriber::set_default(subscriber);
|
||||||
|
|
||||||
|
let (entry_tx, entry_rx) = mpsc::channel(4);
|
||||||
|
let (result_tx, result_rx) = mpsc::channel(1);
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
|
||||||
|
entry_tx
|
||||||
|
.send(test_meta_entry("obj-a"))
|
||||||
|
.await
|
||||||
|
.expect("test entry should be queued");
|
||||||
|
drop(entry_tx);
|
||||||
|
drop(result_rx);
|
||||||
|
|
||||||
|
let state = timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
gather_results(
|
||||||
|
cancel.clone(),
|
||||||
|
ListPathOptions {
|
||||||
|
bucket: "bucket".to_owned(),
|
||||||
|
limit: 8,
|
||||||
|
incl_deleted: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
entry_rx,
|
||||||
|
result_tx,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("gather_results should finish promptly when the consumer is gone")
|
||||||
|
.expect("consumer disconnect must be a benign completion, not an error");
|
||||||
|
assert_eq!(state, GatherResultsState::ConsumerGone);
|
||||||
|
|
||||||
|
let captured = logs.contents();
|
||||||
|
// The consumer disconnect still surfaces as a debug-level ops signal,
|
||||||
|
// proving the subscriber captured output (non-vacuous assertion)...
|
||||||
|
assert!(captured.contains("consumer disconnected"));
|
||||||
|
// ...but it must never be logged as a worker failure. The wrappers'
|
||||||
|
// ConsumerGone arm contains no log call, so a direct gather_results
|
||||||
|
// invocation covers the only code path that could emit this string
|
||||||
|
// for a consumer disconnect.
|
||||||
|
assert!(!captured.contains("list_path worker failed"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_path_forward_past_is_idempotent_for_same_marker() {
|
fn list_path_forward_past_is_idempotent_for_same_marker() {
|
||||||
let mut first_page = sorted_entries(&["obj-0001", "obj-0002", "obj-0003", "obj-0004"]);
|
let mut first_page = sorted_entries(&["obj-0001", "obj-0002", "obj-0003", "obj-0004"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user