mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 20:19:14 +00:00
fix(storage): harden ODM and scanner publication
This commit is contained in:
@@ -560,6 +560,12 @@ pub mod set_disk {
|
|||||||
pub mod test_util {
|
pub mod test_util {
|
||||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||||
|
|
||||||
|
/// Keep a namespace commit pending until the returned owner is dropped.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hold_namespace_commit(store: &crate::store::ECStore) -> impl Send + Sync {
|
||||||
|
store.ctx.begin_namespace_commit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
//! [`BACKFILL_SAVE_INTERVAL`], and at every page end, with an `If-Match`
|
//! [`BACKFILL_SAVE_INTERVAL`], and at every page end, with an `If-Match`
|
||||||
//! compare-and-set so a concurrent cancel or takeover is never overwritten.
|
//! compare-and-set so a concurrent cancel or takeover is never overwritten.
|
||||||
//! - The `continuation_token` only advances once every pull queued from the
|
//! - The `continuation_token` only advances once every pull queued from the
|
||||||
//! page before it has reported back, so a crash re-lists at most one page
|
//! page before it has succeeded. After a failure it stays at that page,
|
||||||
//! (already-present keys are then skipped, never re-pulled).
|
//! so crash recovery cannot skip failed pulls (existing keys are skipped).
|
||||||
//! - The owner holds a lease of [`BACKFILL_LEASE`] renewed by every save. The
|
//! - The owner holds a lease of [`BACKFILL_LEASE`] renewed by every save. The
|
||||||
//! recovery loop ([`run_backfill_recovery_loop`]) scans the buckets this
|
//! recovery loop ([`run_backfill_recovery_loop`]) scans the buckets this
|
||||||
//! node has an ODM state for every [`BACKFILL_RECOVERY_INTERVAL`] and takes
|
//! node has an ODM state for every [`BACKFILL_RECOVERY_INTERVAL`] and takes
|
||||||
@@ -367,9 +367,8 @@ pub struct LocalBackfillObject {
|
|||||||
pub source_etag: Option<String>,
|
pub source_etag: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receiver of one queued pull's report; `None` when the pull was coalesced
|
/// Shared report of a new or coalesced pull; absent only when not admitted.
|
||||||
/// into one already running.
|
pub type PullReport = Option<super::pull::QueuedPullReport>;
|
||||||
pub type PullReport = Option<oneshot::Receiver<QueuedPullOutcome>>;
|
|
||||||
|
|
||||||
/// Everything the job needs from its bucket, so the loop can run against a
|
/// Everything the job needs from its bucket, so the loop can run against a
|
||||||
/// mock in unit tests. Production: [`BucketBackfillContext`].
|
/// mock in unit tests. Production: [`BucketBackfillContext`].
|
||||||
@@ -1190,9 +1189,10 @@ impl Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn main_loop(&mut self) -> Result<(), Stop> {
|
async fn main_loop(&mut self) -> Result<(), Stop> {
|
||||||
|
let mut cursor = self.checkpoint.continuation_token.clone();
|
||||||
loop {
|
loop {
|
||||||
self.check_cancel()?;
|
self.check_cancel()?;
|
||||||
let page = self.list_page().await?;
|
let page = self.list_page(cursor.as_deref()).await?;
|
||||||
for object in &page.objects {
|
for object in &page.objects {
|
||||||
self.check_cancel()?;
|
self.check_cancel()?;
|
||||||
self.checkpoint.listed += 1;
|
self.checkpoint.listed += 1;
|
||||||
@@ -1204,10 +1204,13 @@ impl Job {
|
|||||||
self.drain_ready();
|
self.drain_ready();
|
||||||
self.tick(false).await?;
|
self.tick(false).await?;
|
||||||
}
|
}
|
||||||
// Only advance the cursor once every pull of this page reported
|
// A persisted cursor certifies successful work, not just listing
|
||||||
// back, so a takeover re-lists at most this page.
|
// progress. Keep it at the first failed page for crash recovery.
|
||||||
self.drain_all().await?;
|
self.drain_all().await?;
|
||||||
self.checkpoint.continuation_token = page.next_continuation_token.clone();
|
cursor = page.next_continuation_token;
|
||||||
|
if self.checkpoint.failed == 0 {
|
||||||
|
self.checkpoint.continuation_token = cursor.clone();
|
||||||
|
}
|
||||||
self.tick(true).await?;
|
self.tick(true).await?;
|
||||||
if !page.is_truncated {
|
if !page.is_truncated {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1222,7 +1225,7 @@ impl Job {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_page(&mut self) -> Result<SourcePage, Stop> {
|
async fn list_page(&mut self, cursor: Option<&str>) -> Result<SourcePage, Stop> {
|
||||||
let mut attempt = 0;
|
let mut attempt = 0;
|
||||||
loop {
|
loop {
|
||||||
while !self.context.source_available() {
|
while !self.context.source_available() {
|
||||||
@@ -1230,7 +1233,7 @@ impl Job {
|
|||||||
self.tick(false).await?;
|
self.tick(false).await?;
|
||||||
}
|
}
|
||||||
let prefix = self.checkpoint.prefix.clone();
|
let prefix = self.checkpoint.prefix.clone();
|
||||||
let token = self.checkpoint.continuation_token.clone();
|
let token = cursor.map(str::to_string);
|
||||||
match self
|
match self
|
||||||
.context
|
.context
|
||||||
.list_page(prefix.as_deref(), token.as_deref(), BACKFILL_LIST_PAGE_SIZE)
|
.list_page(prefix.as_deref(), token.as_deref(), BACKFILL_LIST_PAGE_SIZE)
|
||||||
@@ -1304,9 +1307,10 @@ impl Job {
|
|||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
match self.context.enqueue(key) {
|
match self.context.enqueue(key) {
|
||||||
(EnqueueOutcome::Enqueued, report) => {
|
(EnqueueOutcome::Enqueued | EnqueueOutcome::Coalesced, report) => {
|
||||||
self.checkpoint.enqueued += 1;
|
self.checkpoint.enqueued += 1;
|
||||||
if let Some(rx) = report {
|
let rx = report.ok_or(Stop::Unavailable)?;
|
||||||
|
{
|
||||||
let key = key.to_string();
|
let key = key.to_string();
|
||||||
self.outstanding.push(Box::pin(async move { (key, rx.await) }));
|
self.outstanding.push(Box::pin(async move { (key, rx.await) }));
|
||||||
}
|
}
|
||||||
@@ -1321,11 +1325,6 @@ impl Job {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
(EnqueueOutcome::Coalesced, _) => {
|
|
||||||
// Someone else pulls it; its result is not ours to count.
|
|
||||||
self.checkpoint.enqueued += 1;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
(EnqueueOutcome::QueueFull, _) => {
|
(EnqueueOutcome::QueueFull, _) => {
|
||||||
// Wait, never drop: one completion frees a slot.
|
// Wait, never drop: one completion frees a slot.
|
||||||
if self.outstanding.is_empty() {
|
if self.outstanding.is_empty() {
|
||||||
@@ -1639,6 +1638,7 @@ mod tests {
|
|||||||
queue_capacity: usize,
|
queue_capacity: usize,
|
||||||
pending: Mutex<Vec<(String, oneshot::Sender<QueuedPullOutcome>)>>,
|
pending: Mutex<Vec<(String, oneshot::Sender<QueuedPullOutcome>)>>,
|
||||||
fail_keys: HashSet<String>,
|
fail_keys: HashSet<String>,
|
||||||
|
coalesced: bool,
|
||||||
auto_complete: AtomicBool,
|
auto_complete: AtomicBool,
|
||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
config_updated_at: Mutex<Option<OffsetDateTime>>,
|
config_updated_at: Mutex<Option<OffsetDateTime>>,
|
||||||
@@ -1666,6 +1666,7 @@ mod tests {
|
|||||||
queue_capacity: usize::MAX,
|
queue_capacity: usize::MAX,
|
||||||
pending: Mutex::new(Vec::new()),
|
pending: Mutex::new(Vec::new()),
|
||||||
fail_keys: HashSet::new(),
|
fail_keys: HashSet::new(),
|
||||||
|
coalesced: false,
|
||||||
auto_complete: AtomicBool::new(true),
|
auto_complete: AtomicBool::new(true),
|
||||||
cancel: CancellationToken::new(),
|
cancel: CancellationToken::new(),
|
||||||
config_updated_at: Mutex::new(Some(ts(1_700_000_000))),
|
config_updated_at: Mutex::new(Some(ts(1_700_000_000))),
|
||||||
@@ -1745,7 +1746,12 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
self.pending.lock().push((key.to_string(), tx));
|
self.pending.lock().push((key.to_string(), tx));
|
||||||
}
|
}
|
||||||
(EnqueueOutcome::Enqueued, Some(rx))
|
let outcome = if self.coalesced {
|
||||||
|
EnqueueOutcome::Coalesced
|
||||||
|
} else {
|
||||||
|
EnqueueOutcome::Enqueued
|
||||||
|
};
|
||||||
|
(outcome, Some(futures::FutureExt::shared(rx)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_token(&self) -> CancellationToken {
|
fn cancel_token(&self) -> CancellationToken {
|
||||||
@@ -1911,7 +1917,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn failed_pulls_are_counted_hashed_and_finish_with_failures() {
|
async fn failed_pulls_are_counted_hashed_and_finish_with_failures() {
|
||||||
let bucket = "backfill-failed";
|
let bucket = "backfill-failed";
|
||||||
let mut context = MockContext::new(5, 1000);
|
let mut context = MockContext::new(5, 2);
|
||||||
Arc::get_mut(&mut context)
|
Arc::get_mut(&mut context)
|
||||||
.expect("unshared")
|
.expect("unshared")
|
||||||
.fail_keys
|
.fail_keys
|
||||||
@@ -1926,12 +1932,52 @@ mod tests {
|
|||||||
.checkpoint;
|
.checkpoint;
|
||||||
assert_eq!(cp.state, BackfillState::CompletedWithFailures);
|
assert_eq!(cp.state, BackfillState::CompletedWithFailures);
|
||||||
assert_eq!((cp.pulled, cp.failed), (4, 1));
|
assert_eq!((cp.pulled, cp.failed), (4, 1));
|
||||||
|
assert_eq!(cp.continuation_token.as_deref(), Some("2"), "retain the first failed page for recovery");
|
||||||
assert_eq!(cp.failed_keys, vec![key_hash("k/00002")]);
|
assert_eq!(cp.failed_keys, vec![key_hash("k/00002")]);
|
||||||
let last = cp.last_error.expect("last error");
|
let last = cp.last_error.expect("last error");
|
||||||
assert_eq!(last.class, "local_write");
|
assert_eq!(last.class, "local_write");
|
||||||
assert_eq!(last.key_hash.as_deref(), Some(key_hash("k/00002").as_str()));
|
assert_eq!(last.key_hash.as_deref(), Some(key_hash("k/00002").as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn coalesced_pulls_block_the_checkpoint_and_report_failures() {
|
||||||
|
let bucket = "backfill-coalesced";
|
||||||
|
let mut context = MockContext::new(1, 1);
|
||||||
|
{
|
||||||
|
let ctx = Arc::get_mut(&mut context).expect("unshared");
|
||||||
|
ctx.coalesced = true;
|
||||||
|
ctx.auto_complete = AtomicBool::new(false);
|
||||||
|
ctx.fail_keys.insert("k/00000".to_string());
|
||||||
|
}
|
||||||
|
let (_dirs, store, runner) = runner_with("node-a", bucket, Arc::clone(&context)).await;
|
||||||
|
runner.start(bucket, BackfillRequest::default()).await.expect("start");
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
while context.pending.lock().is_empty() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("job enqueued");
|
||||||
|
assert!(runner.is_running_locally(bucket), "coalescing is not completion");
|
||||||
|
let cp = read_checkpoint(&store, bucket)
|
||||||
|
.await
|
||||||
|
.expect("read")
|
||||||
|
.expect("checkpoint")
|
||||||
|
.checkpoint;
|
||||||
|
assert!(cp.state.is_active());
|
||||||
|
assert!(cp.continuation_token.is_none());
|
||||||
|
context.complete_pending();
|
||||||
|
runner.wait_until_idle(bucket).await;
|
||||||
|
let cp = read_checkpoint(&store, bucket)
|
||||||
|
.await
|
||||||
|
.expect("read")
|
||||||
|
.expect("checkpoint")
|
||||||
|
.checkpoint;
|
||||||
|
assert_eq!(cp.state, BackfillState::CompletedWithFailures);
|
||||||
|
assert_eq!((cp.enqueued, cp.pulled, cp.failed), (1, 0, 1));
|
||||||
|
assert_eq!(cp.failed_keys, vec![key_hash("k/00000")]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn listing_failure_marks_the_job_failed_with_the_error_class() {
|
async fn listing_failure_marks_the_job_failed_with_the_error_class() {
|
||||||
let bucket = "backfill-list-error";
|
let bucket = "backfill-list-error";
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
|
|||||||
/// listing's own marker, so the decoder needs a positive signal before it
|
/// listing's own marker, so the decoder needs a positive signal before it
|
||||||
/// treats an opaque token as a merged one.
|
/// treats an opaque token as a merged one.
|
||||||
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
|
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
|
||||||
|
// Object keys cannot contain NUL (bucket::utils::is_valid_object_prefix),
|
||||||
|
// so this framing cannot collide with a local key used as an opaque marker.
|
||||||
|
const LIST_THROUGH_TOKEN_PREFIX: &str = "\0odm-list:";
|
||||||
|
|
||||||
/// Pages fetched per side per request: the first page, plus at most one refill
|
/// Pages fetched per side per request: the first page, plus at most one refill
|
||||||
/// when the first one was mostly consumed by the previous page. Two pages of
|
/// when the first one was mostly consumed by the previous page. Two pages of
|
||||||
@@ -86,8 +89,7 @@ pub struct MergePick {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The continuation-token envelope. Opaque to clients: it is serialized as
|
/// The continuation-token envelope. Opaque to clients: it is serialized as
|
||||||
/// JSON and then base64-encoded by the same helper that encodes a plain local
|
/// framed JSON and then base64-encoded by the same helper as a local marker.
|
||||||
/// marker, so the wire shape is `base64(json)`.
|
|
||||||
///
|
///
|
||||||
/// A `null` cursor with `done = false` means "list that side from the start";
|
/// A `null` cursor with `done = false` means "list that side from the start";
|
||||||
/// `done = true` means the side is finished and must not be listed again.
|
/// `done = true` means the side is finished and must not be listed again.
|
||||||
@@ -129,7 +131,7 @@ impl ListThroughToken {
|
|||||||
pub fn encode(&self) -> String {
|
pub fn encode(&self) -> String {
|
||||||
// The envelope is built here from owned strings, so serialization
|
// The envelope is built here from owned strings, so serialization
|
||||||
// cannot fail; the fallback keeps the signature infallible.
|
// cannot fail; the fallback keeps the signature infallible.
|
||||||
serde_json::to_string(self).unwrap_or_default()
|
format!("{LIST_THROUGH_TOKEN_PREFIX}{}", serde_json::to_string(self).unwrap_or_default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,21 +155,18 @@ pub enum ListThroughTokenError {
|
|||||||
|
|
||||||
/// Classifies an already base64-decoded continuation token.
|
/// Classifies an already base64-decoded continuation token.
|
||||||
///
|
///
|
||||||
/// Only a JSON object carrying the envelope marker is read as a merged token;
|
/// Only a framed JSON object is read as a merged token;
|
||||||
/// anything else is a local marker, so a bucket that turns `list_through` off
|
/// anything else is a local marker, so a bucket that turns `list_through` off
|
||||||
/// keeps paginating with the tokens it handed out. A token that *is* an
|
/// keeps paginating with the tokens it handed out. A token that *is* an
|
||||||
/// envelope but was tampered with (unknown version, unknown field, truncated
|
/// envelope but was tampered with (unknown version, unknown field, truncated
|
||||||
/// JSON) is an error, never a silent fallback.
|
/// JSON) is an error, never a silent fallback.
|
||||||
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
|
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
|
||||||
if !decoded.starts_with('{') {
|
let Some(payload) = decoded.strip_prefix(LIST_THROUGH_TOKEN_PREFIX) else {
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
|
||||||
}
|
|
||||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
|
|
||||||
// Not JSON at all: an object key may legitimately start with '{'.
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
||||||
};
|
};
|
||||||
|
let value = serde_json::from_str::<serde_json::Value>(payload).map_err(|_| ListThroughTokenError::Malformed)?;
|
||||||
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
|
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
return Err(ListThroughTokenError::Malformed);
|
||||||
}
|
}
|
||||||
match value.get("v").and_then(serde_json::Value::as_u64) {
|
match value.get("v").and_then(serde_json::Value::as_u64) {
|
||||||
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {}
|
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {}
|
||||||
@@ -718,14 +717,21 @@ mod tests {
|
|||||||
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
|
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
|
||||||
|
|
||||||
let truncated = &encoded[..encoded.len() - 3];
|
let truncated = &encoded[..encoded.len() - 3];
|
||||||
assert_eq!(decode_continuation_token(truncated), Ok(ListThroughCursor::Local(truncated.to_string())));
|
assert_eq!(decode_continuation_token(truncated), Err(ListThroughTokenError::Malformed));
|
||||||
|
|
||||||
let no_version = "{\"t\":\"odm-list\"}";
|
let no_version = "\0odm-list:{\"t\":\"odm-list\"}";
|
||||||
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
|
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_plain_local_marker_stays_local() {
|
fn a_plain_local_marker_stays_local() {
|
||||||
|
for marker in [
|
||||||
|
r#"{"t":"odm-list","v":1}"#,
|
||||||
|
r#"{"t":"odm-list","v":2,"local_done":true}"#,
|
||||||
|
r#"{"t":"odm-list"}"#,
|
||||||
|
] {
|
||||||
|
assert_eq!(decode_continuation_token(marker), Ok(ListThroughCursor::Local(marker.to_string())));
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_continuation_token("photos/2024/01.jpg"),
|
decode_continuation_token("photos/2024/01.jpg"),
|
||||||
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
|
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ use super::stats::{PullFailureReason, PullPath};
|
|||||||
use super::sys::{BucketOdmState, OnDemandMigrationSys, PullError, PullOutcome, PullSlot};
|
use super::sys::{BucketOdmState, OnDemandMigrationSys, PullError, PullOutcome, PullSlot};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::{Stream, StreamExt};
|
use futures::{FutureExt, Stream, StreamExt, future::Shared};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -133,6 +133,8 @@ pub enum QueuedPullOutcome {
|
|||||||
Failed(PullError),
|
Failed(PullError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type QueuedPullReport = Shared<oneshot::Receiver<QueuedPullOutcome>>;
|
||||||
|
|
||||||
/// Result of [`PullQueue::enqueue`].
|
/// Result of [`PullQueue::enqueue`].
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub enum EnqueueOutcome {
|
pub enum EnqueueOutcome {
|
||||||
@@ -251,6 +253,7 @@ pub struct WriteBackRequest {
|
|||||||
pub preserve_etag: bool,
|
pub preserve_etag: bool,
|
||||||
/// `policy.emit_events`.
|
/// `policy.emit_events`.
|
||||||
pub emit_events: bool,
|
pub emit_events: bool,
|
||||||
|
pub respect_delete_marker: bool,
|
||||||
/// Source tags to copy (`policy.copy_tags`), `None` to skip.
|
/// Source tags to copy (`policy.copy_tags`), `None` to skip.
|
||||||
pub tags: Option<HashMap<String, String>>,
|
pub tags: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
@@ -266,6 +269,7 @@ impl WriteBackRequest {
|
|||||||
pulled_at: OffsetDateTime::now_utc(),
|
pulled_at: OffsetDateTime::now_utc(),
|
||||||
preserve_etag: config.policy.preserve_etag,
|
preserve_etag: config.policy.preserve_etag,
|
||||||
emit_events: config.policy.emit_events,
|
emit_events: config.policy.emit_events,
|
||||||
|
respect_delete_marker: config.policy.respect_local_delete_marker,
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,7 +834,7 @@ pub struct PullQueue {
|
|||||||
bucket: String,
|
bucket: String,
|
||||||
tx: mpsc::Sender<PullJob>,
|
tx: mpsc::Sender<PullJob>,
|
||||||
/// Keys queued or running; the job removes its key when it ends.
|
/// Keys queued or running; the job removes its key when it ends.
|
||||||
pending: Mutex<HashSet<String>>,
|
pending: Mutex<HashMap<String, QueuedPullReport>>,
|
||||||
capacity: usize,
|
capacity: usize,
|
||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
stats: Arc<super::stats::OdmStats>,
|
stats: Arc<super::stats::OdmStats>,
|
||||||
@@ -869,7 +873,7 @@ impl PullQueue {
|
|||||||
let queue = Arc::new(Self {
|
let queue = Arc::new(Self {
|
||||||
bucket: state.bucket().to_string(),
|
bucket: state.bucket().to_string(),
|
||||||
tx,
|
tx,
|
||||||
pending: Mutex::new(HashSet::new()),
|
pending: Mutex::new(HashMap::new()),
|
||||||
capacity,
|
capacity,
|
||||||
cancel: state.cancel_token(),
|
cancel: state.cancel_token(),
|
||||||
stats: Arc::clone(state.stats()),
|
stats: Arc::clone(state.stats()),
|
||||||
@@ -903,29 +907,24 @@ impl PullQueue {
|
|||||||
self.enqueue_with_report(key, reason).0
|
self.enqueue_with_report(key, reason).0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`Self::enqueue`] that also hands back the job's report channel when
|
/// [`Self::enqueue`] with a shared report, including for coalesced pulls.
|
||||||
/// a new job was queued (`Coalesced` pulls report to their first
|
pub fn enqueue_with_report(&self, key: &str, reason: PullReason) -> (EnqueueOutcome, Option<QueuedPullReport>) {
|
||||||
/// requester only).
|
|
||||||
pub fn enqueue_with_report(
|
|
||||||
&self,
|
|
||||||
key: &str,
|
|
||||||
reason: PullReason,
|
|
||||||
) -> (EnqueueOutcome, Option<oneshot::Receiver<QueuedPullOutcome>>) {
|
|
||||||
if self.cancel.is_cancelled() {
|
if self.cancel.is_cancelled() {
|
||||||
return (EnqueueOutcome::Unavailable, None);
|
return (EnqueueOutcome::Unavailable, None);
|
||||||
}
|
}
|
||||||
let mut pending = self.pending.lock();
|
let mut pending = self.pending.lock();
|
||||||
if pending.contains(key) {
|
if let Some(report) = pending.get(key) {
|
||||||
return (EnqueueOutcome::Coalesced, None);
|
return (EnqueueOutcome::Coalesced, Some(report.clone()));
|
||||||
}
|
}
|
||||||
let (report_tx, report_rx) = oneshot::channel();
|
let (report_tx, report_rx) = oneshot::channel();
|
||||||
|
let report_rx = report_rx.shared();
|
||||||
match self.tx.try_send(PullJob {
|
match self.tx.try_send(PullJob {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
reason,
|
reason,
|
||||||
report: Some(report_tx),
|
report: Some(report_tx),
|
||||||
}) {
|
}) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
pending.insert(key.to_string());
|
pending.insert(key.to_string(), report_rx.clone());
|
||||||
(EnqueueOutcome::Enqueued, Some(report_rx))
|
(EnqueueOutcome::Enqueued, Some(report_rx))
|
||||||
}
|
}
|
||||||
Err(TrySendError::Full(_)) => {
|
Err(TrySendError::Full(_)) => {
|
||||||
@@ -1072,7 +1071,7 @@ impl BucketOdmState {
|
|||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
key: &str,
|
key: &str,
|
||||||
reason: PullReason,
|
reason: PullReason,
|
||||||
) -> (EnqueueOutcome, Option<oneshot::Receiver<QueuedPullOutcome>>) {
|
) -> (EnqueueOutcome, Option<QueuedPullReport>) {
|
||||||
match self.pull_queue() {
|
match self.pull_queue() {
|
||||||
Some(queue) => queue.enqueue_with_report(key, reason),
|
Some(queue) => queue.enqueue_with_report(key, reason),
|
||||||
None => (EnqueueOutcome::Unavailable, None),
|
None => (EnqueueOutcome::Unavailable, None),
|
||||||
@@ -1399,13 +1398,21 @@ mod tests {
|
|||||||
assert_eq!(queue.capacity(), 1024);
|
assert_eq!(queue.capacity(), 1024);
|
||||||
|
|
||||||
let mut outcomes = HashMap::new();
|
let mut outcomes = HashMap::new();
|
||||||
|
let mut shared_report = None;
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
*outcomes.entry(queue.enqueue("a", PullReason::RangeGet)).or_insert(0) += 1;
|
let (outcome, report) = queue.enqueue_with_report("a", PullReason::RangeGet);
|
||||||
|
*outcomes.entry(outcome).or_insert(0) += 1;
|
||||||
|
shared_report = report;
|
||||||
}
|
}
|
||||||
assert_eq!(outcomes.get(&EnqueueOutcome::Enqueued), Some(&1));
|
assert_eq!(outcomes.get(&EnqueueOutcome::Enqueued), Some(&1));
|
||||||
assert_eq!(outcomes.get(&EnqueueOutcome::Coalesced), Some(&99));
|
assert_eq!(outcomes.get(&EnqueueOutcome::Coalesced), Some(&99));
|
||||||
assert_eq!(queue.pending_keys(), 1);
|
assert_eq!(queue.pending_keys(), 1);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
shared_report.expect("coalesced report").await,
|
||||||
|
Ok(QueuedPullOutcome::Stored { size: 1000 })
|
||||||
|
);
|
||||||
|
|
||||||
wait_until("first pull to finish", || queue.pending_keys() == 0).await;
|
wait_until("first pull to finish", || queue.pending_keys() == 0).await;
|
||||||
assert_eq!(source.head_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(source.head_calls.load(Ordering::SeqCst), 1);
|
||||||
assert_eq!(source.get_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(source.get_calls.load(Ordering::SeqCst), 1);
|
||||||
@@ -1438,6 +1445,23 @@ mod tests {
|
|||||||
assert_eq!(queue.enqueue("a", PullReason::RangeGet), EnqueueOutcome::Unavailable);
|
assert_eq!(queue.enqueue("a", PullReason::RangeGet), EnqueueOutcome::Unavailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn coalesced_enqueues_share_failure_reports() {
|
||||||
|
let sys = OnDemandMigrationSys::new();
|
||||||
|
let state = enabled_state(&sys, &config()).await;
|
||||||
|
let source = MockSource::with_object("missing", 1000, BodyKind::Bytes(body_bytes(1000)));
|
||||||
|
let queue = PullQueue::start(Arc::clone(&state), source, Arc::new(MockWriteBack::default()));
|
||||||
|
let (first, first_report) = queue.enqueue_with_report("absent", PullReason::RangeGet);
|
||||||
|
let (second, second_report) = queue.enqueue_with_report("absent", PullReason::Backfill);
|
||||||
|
assert_eq!(first, EnqueueOutcome::Enqueued);
|
||||||
|
assert_eq!(second, EnqueueOutcome::Coalesced);
|
||||||
|
let (first, second) = tokio::join!(first_report.expect("leader report"), second_report.expect("coalesced report"));
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert!(matches!(first, Ok(QueuedPullOutcome::Failed(_))));
|
||||||
|
sys.remove(BUCKET);
|
||||||
|
queue.wait_until_stopped().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn queue_full_is_reported_and_cancel_drains_without_leaking_tasks() {
|
async fn queue_full_is_reported_and_cancel_drains_without_leaking_tasks() {
|
||||||
let sys = OnDemandMigrationSys::new();
|
let sys = OnDemandMigrationSys::new();
|
||||||
@@ -1467,7 +1491,8 @@ mod tests {
|
|||||||
wait_until("dispatcher to wait for a slot", || state.stats().queue_depth() == 1).await;
|
wait_until("dispatcher to wait for a slot", || state.stats().queue_depth() == 1).await;
|
||||||
assert_eq!(queue.enqueue("c", PullReason::LargeObject), EnqueueOutcome::Enqueued);
|
assert_eq!(queue.enqueue("c", PullReason::LargeObject), EnqueueOutcome::Enqueued);
|
||||||
assert_eq!(queue.enqueue("d", PullReason::LargeObject), EnqueueOutcome::QueueFull);
|
assert_eq!(queue.enqueue("d", PullReason::LargeObject), EnqueueOutcome::QueueFull);
|
||||||
assert_eq!(queue.enqueue("c", PullReason::LargeObject), EnqueueOutcome::Coalesced);
|
let (coalesced, canceled_report) = queue.enqueue_with_report("c", PullReason::LargeObject);
|
||||||
|
assert_eq!(coalesced, EnqueueOutcome::Coalesced);
|
||||||
assert_eq!(queue.pending_keys(), 3);
|
assert_eq!(queue.pending_keys(), 3);
|
||||||
assert_eq!(failures(&state).get("queue_full"), Some(&1));
|
assert_eq!(failures(&state).get("queue_full"), Some(&1));
|
||||||
assert!(!queue.is_stopped());
|
assert!(!queue.is_stopped());
|
||||||
@@ -1477,6 +1502,12 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("dispatcher and in-flight job must exit after cancel");
|
.expect("dispatcher and in-flight job must exit after cancel");
|
||||||
assert!(queue.is_stopped());
|
assert!(queue.is_stopped());
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), canceled_report.expect("coalesced cancellation report"))
|
||||||
|
.await
|
||||||
|
.expect("cancellation closes the report")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert_eq!(queue.pending_keys(), 0);
|
assert_eq!(queue.pending_keys(), 0);
|
||||||
assert_eq!(state.inflight_keys(), 0);
|
assert_eq!(state.inflight_keys(), 0);
|
||||||
assert_eq!(state.stats().inflight_pulls(), 0);
|
assert_eq!(state.stats().inflight_pulls(), 0);
|
||||||
|
|||||||
@@ -152,8 +152,8 @@ pub struct SourceClientSpec {
|
|||||||
/// Wire requests one logical source call may cost. The pull pipeline and
|
/// Wire requests one logical source call may cost. The pull pipeline and
|
||||||
/// the backfill job own the retry budget (`pull.rs` `PULL_MAX_RETRIES`,
|
/// the backfill job own the retry budget (`pull.rs` `PULL_MAX_RETRIES`,
|
||||||
/// `backfill.rs` `LIST_MAX_RETRIES`) and the breaker counts logical calls,
|
/// `backfill.rs` `LIST_MAX_RETRIES`) and the breaker counts logical calls,
|
||||||
/// so ODM declares [`RemoteS3RetryPolicy::Disabled`] and keeps one counted
|
/// so ODM declares [`RemoteS3RetryPolicy::Disabled`]. An ambiguous HEAD
|
||||||
/// failure equal to one request against a struggling source.
|
/// 404 additionally probes the bucket before declaring a key absent.
|
||||||
pub retry: RemoteS3RetryPolicy,
|
pub retry: RemoteS3RetryPolicy,
|
||||||
/// Bytes per second the pull pipeline may consume from this source;
|
/// Bytes per second the pull pipeline may consume from this source;
|
||||||
/// `None` means unlimited. Enforced by the consumer, not by this client.
|
/// `None` means unlimited. Enforced by the consumer, not by this client.
|
||||||
@@ -258,7 +258,7 @@ const THROTTLE_CODES: &[&str] = &[
|
|||||||
"TooManyRequests",
|
"TooManyRequests",
|
||||||
"RequestThrottled",
|
"RequestThrottled",
|
||||||
];
|
];
|
||||||
const NOT_FOUND_CODES: &[&str] = &["NoSuchKey", "NotFound", "NoSuchBucket", "NoSuchVersion"];
|
const NOT_FOUND_CODES: &[&str] = &["NoSuchKey"];
|
||||||
const ACCESS_DENIED_CODES: &[&str] = &[
|
const ACCESS_DENIED_CODES: &[&str] = &[
|
||||||
"AccessDenied",
|
"AccessDenied",
|
||||||
"InvalidAccessKeyId",
|
"InvalidAccessKeyId",
|
||||||
@@ -281,7 +281,6 @@ fn classify_status(status: u16, code: Option<&str>, message: String) -> SourceEr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match status {
|
match status {
|
||||||
404 => SourceError::NotFound,
|
|
||||||
401 | 403 => SourceError::AccessDenied,
|
401 | 403 => SourceError::AccessDenied,
|
||||||
429 | 503 => SourceError::Throttled,
|
429 | 503 => SourceError::Throttled,
|
||||||
500..=599 => SourceError::ServerError(status),
|
500..=599 => SourceError::ServerError(status),
|
||||||
@@ -627,8 +626,7 @@ impl SourceClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `config` must come from [`SourceClientSpec::endpoint_spec`], which is
|
/// `config` must come from [`SourceClientSpec::endpoint_spec`], which is
|
||||||
/// where the retry policy that keeps one logical call equal to one wire
|
/// where the policy disabling SDK-level retries is declared.
|
||||||
/// request is declared.
|
|
||||||
fn from_config_builder(config: aws_sdk_s3::config::Builder, endpoint: String, spec: &SourceClientSpec) -> Self {
|
fn from_config_builder(config: aws_sdk_s3::config::Builder, endpoint: String, spec: &SourceClientSpec) -> Self {
|
||||||
let client = S3Client::from_conf(config.interceptor(SourceProxyMarkerInterceptor::new()).build());
|
let client = S3Client::from_conf(config.interceptor(SourceProxyMarkerInterceptor::new()).build());
|
||||||
Self {
|
Self {
|
||||||
@@ -749,15 +747,16 @@ impl SourceClient {
|
|||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl SourceBackend for S3SourceBackend {
|
impl SourceBackend for S3SourceBackend {
|
||||||
async fn head(&self, key: &str) -> Result<SourceHead, SourceError> {
|
async fn head(&self, key: &str) -> Result<SourceHead, SourceError> {
|
||||||
let output = self
|
match self.client.head_object().bucket(&self.bucket).key(key).send().await {
|
||||||
.client
|
Ok(output) => source_head_from_head_output(output),
|
||||||
.head_object()
|
Err(err) if err.raw_response().is_some_and(|response| response.status().as_u16() == 404) => {
|
||||||
.bucket(&self.bucket)
|
// HEAD has no error body: a missing bucket must not poison
|
||||||
.key(key)
|
// the per-key negative cache as though only the key was absent.
|
||||||
.send()
|
self.probe().await?;
|
||||||
.await
|
Err(SourceError::NotFound)
|
||||||
.map_err(classify_sdk_error)?;
|
}
|
||||||
source_head_from_head_output(output)
|
Err(err) => Err(classify_sdk_error(err)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streams the object; `range` is passed through as an HTTP `Range`
|
/// Streams the object; `range` is passed through as an HTTP `Range`
|
||||||
@@ -809,8 +808,8 @@ impl SourceBackend for S3SourceBackend {
|
|||||||
.contents
|
.contents
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(s3_source_object)
|
.map(s3_source_object)
|
||||||
.collect();
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
let common_prefixes = output
|
let common_prefixes = output
|
||||||
.common_prefixes
|
.common_prefixes
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -849,14 +848,20 @@ impl SourceBackend for S3SourceBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn s3_source_object(object: SdkObject) -> Option<SourceObject> {
|
fn s3_source_object(object: SdkObject) -> Result<SourceObject, SourceError> {
|
||||||
let key = object.key?;
|
let key = object
|
||||||
|
.key
|
||||||
|
.ok_or_else(|| SourceError::Other("source listing object has no key".to_string()))?;
|
||||||
|
let size = object
|
||||||
|
.size
|
||||||
|
.and_then(|size| u64::try_from(size).ok())
|
||||||
|
.ok_or_else(|| SourceError::Other("source listing object has no valid size".to_string()))?;
|
||||||
let etag = normalize_etag(object.e_tag);
|
let etag = normalize_etag(object.e_tag);
|
||||||
let is_multipart_etag = etag.as_deref().is_some_and(is_multipart_etag);
|
let is_multipart_etag = etag.as_deref().is_some_and(is_multipart_etag);
|
||||||
Some(SourceObject {
|
Ok(SourceObject {
|
||||||
key,
|
key,
|
||||||
etag,
|
etag,
|
||||||
size: object.size.and_then(|size| u64::try_from(size).ok()).unwrap_or(0),
|
size,
|
||||||
last_modified: system_time(object.last_modified),
|
last_modified: system_time(object.last_modified),
|
||||||
storage_class: object.storage_class.map(|class| class.as_str().to_string()),
|
storage_class: object.storage_class.map(|class| class.as_str().to_string()),
|
||||||
is_multipart_etag,
|
is_multipart_etag,
|
||||||
@@ -1390,7 +1395,10 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn source_error_classification_covers_every_class() {
|
async fn source_error_classification_covers_every_class() {
|
||||||
let cases: Vec<(Scripted, &str, bool)> = vec![
|
let cases: Vec<(Scripted, &str, bool)> = vec![
|
||||||
(status(404, ""), "not_found", false),
|
(status(404, ""), "other", false),
|
||||||
|
(status(404, "<Error><Code>NoSuchKey</Code></Error>"), "not_found", false),
|
||||||
|
(status(404, "<Error><Code>NoSuchBucket</Code></Error>"), "other", false),
|
||||||
|
(status(404, "<Error><Code>NoSuchVersion</Code></Error>"), "other", false),
|
||||||
(status(403, ACCESS_DENIED_BODY), "access_denied", false),
|
(status(403, ACCESS_DENIED_BODY), "access_denied", false),
|
||||||
(status(401, ""), "access_denied", false),
|
(status(401, ""), "access_denied", false),
|
||||||
(status(429, ""), "throttled", true),
|
(status(429, ""), "throttled", true),
|
||||||
@@ -1413,14 +1421,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HEAD carries no error body, so the classification must work from the
|
let (client, requests) = scripted_client(&spec(None), vec![status(404, ""), status(200, "")]).await;
|
||||||
// status alone as well.
|
|
||||||
let (client, _) = scripted_client(&spec(None), vec![status(404, "")]).await;
|
|
||||||
assert!(matches!(client.head_object("missing").await, Err(SourceError::NotFound)));
|
assert!(matches!(client.head_object("missing").await, Err(SourceError::NotFound)));
|
||||||
|
assert_eq!(recorded(&requests).len(), 2, "ambiguous HEAD 404 must check the bucket");
|
||||||
|
let (client, _) = scripted_client(&spec(None), vec![status(404, ""), status(404, "")]).await;
|
||||||
|
assert!(matches!(client.head_object("missing").await, Err(SourceError::Other(_))));
|
||||||
|
let (client, _) = scripted_client(&spec(None), vec![status(404, ""), status(403, "")]).await;
|
||||||
|
assert!(matches!(client.head_object("missing").await, Err(SourceError::AccessDenied)));
|
||||||
let (client, _) = scripted_client(&spec(None), vec![status(403, "")]).await;
|
let (client, _) = scripted_client(&spec(None), vec![status(403, "")]).await;
|
||||||
assert!(matches!(client.head_object("secret").await, Err(SourceError::AccessDenied)));
|
assert!(matches!(client.head_object("secret").await, Err(SourceError::AccessDenied)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_listing_rejects_missing_and_negative_sizes() {
|
||||||
|
for size in [None, Some(-1)] {
|
||||||
|
let object = SdkObject::builder().key("key").set_size(size).build();
|
||||||
|
assert!(matches!(s3_source_object(object), Err(SourceError::Other(_))));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
s3_source_object(SdkObject::builder().size(0).build()),
|
||||||
|
Err(SourceError::Other(_))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
s3_source_object(SdkObject::builder().key("empty").size(0).build())
|
||||||
|
.expect("empty object")
|
||||||
|
.size,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn source_client_debug_redacts_credentials() {
|
async fn source_client_debug_redacts_credentials() {
|
||||||
let (client, _) = scripted_client(&spec(Some("data/")), Vec::new()).await;
|
let (client, _) = scripted_client(&spec(Some("data/")), Vec::new()).await;
|
||||||
|
|||||||
@@ -940,6 +940,9 @@ pub struct ObjectOptions {
|
|||||||
pub preserve_etag: Option<String>,
|
pub preserve_etag: Option<String>,
|
||||||
pub metadata_chg: bool,
|
pub metadata_chg: bool,
|
||||||
pub http_preconditions: Option<HTTPPreconditions>,
|
pub http_preconditions: Option<HTTPPreconditions>,
|
||||||
|
/// Internal create-only writes may also preserve an acknowledged deletion.
|
||||||
|
/// Evaluated with `http_preconditions` under the namespace commit lock.
|
||||||
|
pub preserve_delete_marker: bool,
|
||||||
|
|
||||||
pub delete_replication: Option<ReplicationState>,
|
pub delete_replication: Option<ReplicationState>,
|
||||||
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ pub(crate) struct ScannerPublicationLeaseEntry {
|
|||||||
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct NamespaceCommitGuard {
|
||||||
|
ctx: Arc<InstanceContext>,
|
||||||
|
counted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for NamespaceCommitGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.counted {
|
||||||
|
// Publish the new generation before a zero-pending publication probe.
|
||||||
|
self.ctx.advance_namespace_commit_generation();
|
||||||
|
self.ctx.namespace_commits.fetch_sub(1, Ordering::AcqRel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runtime state owned by a single `ECStore` instance.
|
/// Runtime state owned by a single `ECStore` instance.
|
||||||
///
|
///
|
||||||
/// This is intentionally minimal in the first migration slice; subsequent
|
/// This is intentionally minimal in the first migration slice; subsequent
|
||||||
@@ -209,9 +224,13 @@ pub struct InstanceContext {
|
|||||||
/// Last storage-owned movement snapshot observed under the operation
|
/// Last storage-owned movement snapshot observed under the operation
|
||||||
/// gate. SetDisks cache writers fail closed until ECStore refreshes it.
|
/// gate. SetDisks cache writers fail closed until ECStore refreshes it.
|
||||||
scanner_publication_state: AtomicU8,
|
scanner_publication_state: AtomicU8,
|
||||||
|
namespace_commits: AtomicU64,
|
||||||
|
namespace_commit_generation: AtomicU64,
|
||||||
/// Resolves object-encryption material at the application boundary.
|
/// Resolves object-encryption material at the application boundary.
|
||||||
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
|
#[cfg(test)]
|
||||||
|
suppress_tier_delete_journal_recovery: bool,
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
|
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
@@ -256,8 +275,12 @@ impl InstanceContext {
|
|||||||
data_movement_generation_exhausted: AtomicBool::new(false),
|
data_movement_generation_exhausted: AtomicBool::new(false),
|
||||||
data_movement_generation_notify: Arc::new(Notify::new()),
|
data_movement_generation_notify: Arc::new(Notify::new()),
|
||||||
scanner_publication_state: AtomicU8::new(SCANNER_PUBLICATION_STATE_UNKNOWN),
|
scanner_publication_state: AtomicU8::new(SCANNER_PUBLICATION_STATE_UNKNOWN),
|
||||||
|
namespace_commits: AtomicU64::new(0),
|
||||||
|
namespace_commit_generation: AtomicU64::new(0),
|
||||||
object_encryption_resolver: OnceLock::new(),
|
object_encryption_resolver: OnceLock::new(),
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
#[cfg(test)]
|
||||||
|
suppress_tier_delete_journal_recovery: false,
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
|
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
|
||||||
}
|
}
|
||||||
@@ -385,6 +408,36 @@ impl InstanceContext {
|
|||||||
&& self.scanner_publication_state.load(Ordering::Acquire) == SCANNER_PUBLICATION_STATE_ALLOWED
|
&& self.scanner_publication_state.load(Ordering::Acquire) == SCANNER_PUBLICATION_STATE_ALLOWED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin_namespace_commit(self: &Arc<Self>) -> Arc<NamespaceCommitGuard> {
|
||||||
|
let counted = self
|
||||||
|
.namespace_commits
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| count.checked_add(1))
|
||||||
|
.is_ok();
|
||||||
|
if counted {
|
||||||
|
self.advance_namespace_commit_generation();
|
||||||
|
} else {
|
||||||
|
self.namespace_commit_generation.store(u64::MAX, Ordering::Release);
|
||||||
|
}
|
||||||
|
Arc::new(NamespaceCommitGuard {
|
||||||
|
ctx: Arc::clone(self),
|
||||||
|
counted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_namespace_commit_generation(&self) {
|
||||||
|
let _ = self
|
||||||
|
.namespace_commit_generation
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |generation| Some(generation.saturating_add(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn namespace_commit_generation(&self) -> u64 {
|
||||||
|
self.namespace_commit_generation.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn namespace_commits_pending(&self) -> bool {
|
||||||
|
self.namespace_commits.load(Ordering::Acquire) != 0 || self.namespace_commit_generation() == u64::MAX
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn set_scanner_publication_state(&self, blocked: bool) {
|
pub(crate) fn set_scanner_publication_state(&self, blocked: bool) {
|
||||||
self.scanner_publication_state.store(
|
self.scanner_publication_state.store(
|
||||||
if blocked {
|
if blocked {
|
||||||
@@ -640,12 +693,21 @@ impl InstanceContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_tier_delete_journal_recovery_started(&self, store_id: Uuid) -> bool {
|
pub(crate) fn mark_tier_delete_journal_recovery_started(&self, store_id: Uuid) -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
if self.suppress_tier_delete_journal_recovery {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
self.tier_delete_journal_recovery_stores
|
self.tier_delete_journal_recovery_stores
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
.insert(store_id)
|
.insert(store_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn suppress_tier_delete_journal_recovery_for_test(&mut self) {
|
||||||
|
self.suppress_tier_delete_journal_recovery = true;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_transition_transaction_recovery_started(&self, store_id: Uuid) -> bool {
|
pub(crate) fn mark_transition_transaction_recovery_started(&self, store_id: Uuid) -> bool {
|
||||||
self.transition_transaction_recovery_stores
|
self.transition_transaction_recovery_stores
|
||||||
.lock()
|
.lock()
|
||||||
@@ -756,6 +818,50 @@ pub fn bootstrap_ctx() -> Arc<InstanceContext> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_commit_guards_are_instance_local_and_count_until_last_owner() {
|
||||||
|
let first = Arc::new(InstanceContext::new());
|
||||||
|
let other = Arc::new(InstanceContext::new());
|
||||||
|
first.set_scanner_publication_state(false);
|
||||||
|
other.set_scanner_publication_state(false);
|
||||||
|
assert!(first.scanner_publication_state_allowed());
|
||||||
|
let one = first.begin_namespace_commit();
|
||||||
|
let shared_owner = Arc::clone(&one);
|
||||||
|
let two = first.begin_namespace_commit();
|
||||||
|
assert!(first.namespace_commits_pending());
|
||||||
|
assert!(first.scanner_publication_state_allowed(), "pending writes must not block scan admission");
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 2);
|
||||||
|
assert!(!other.namespace_commits_pending());
|
||||||
|
assert_eq!(other.namespace_commit_generation(), 0);
|
||||||
|
assert!(other.scanner_publication_state_allowed());
|
||||||
|
drop(one);
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 2);
|
||||||
|
drop(shared_owner);
|
||||||
|
assert!(first.namespace_commits_pending());
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 3);
|
||||||
|
drop(two);
|
||||||
|
assert!(!first.namespace_commits_pending());
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 4);
|
||||||
|
assert!(first.scanner_publication_state_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_commit_counter_exhaustion_keeps_publication_blocked() {
|
||||||
|
for (count, generation) in [(0, u64::MAX - 1), (u64::MAX, 0)] {
|
||||||
|
let ctx = Arc::new(InstanceContext::new());
|
||||||
|
ctx.set_scanner_publication_state(false);
|
||||||
|
ctx.namespace_commits.store(count, Ordering::Release);
|
||||||
|
ctx.namespace_commit_generation.store(generation, Ordering::Release);
|
||||||
|
let guard = ctx.begin_namespace_commit();
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), u64::MAX);
|
||||||
|
drop(guard);
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), u64::MAX);
|
||||||
|
assert_eq!(ctx.namespace_commits.load(Ordering::Acquire), count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The SetupType inputs must derive the exact (is_erasure,
|
// The SetupType inputs must derive the exact (is_erasure,
|
||||||
// is_dist_erasure, is_erasure_sd) triples that the original three
|
// is_dist_erasure, is_erasure_sd) triples that the original three
|
||||||
// process-global erasure bools produced via update_erasure_type().
|
// process-global erasure bools produced via update_erasure_type().
|
||||||
@@ -1073,6 +1179,12 @@ mod tests {
|
|||||||
assert!(!ctx_a.mark_tier_delete_journal_recovery_started(store_a));
|
assert!(!ctx_a.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_b));
|
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_a));
|
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
|
|
||||||
|
let mut manual_ctx = InstanceContext::new();
|
||||||
|
manual_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||||
|
assert!(!manual_ctx.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
|
assert!(!manual_ctx.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
|
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3845,6 +3845,7 @@ pub(in crate::set_disk) struct RenameDataFenceOptions<'a> {
|
|||||||
write_quorum: usize,
|
write_quorum: usize,
|
||||||
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
|
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
|
||||||
scanner_publication_commit_scope: Option<crate::object_api::ScannerPublicationCommitScope>,
|
scanner_publication_commit_scope: Option<crate::object_api::ScannerPublicationCommitScope>,
|
||||||
|
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> RenameDataFenceOptions<'a> {
|
impl<'a> RenameDataFenceOptions<'a> {
|
||||||
@@ -3856,6 +3857,7 @@ impl<'a> RenameDataFenceOptions<'a> {
|
|||||||
write_quorum,
|
write_quorum,
|
||||||
scanner_publication_lease_tokens,
|
scanner_publication_lease_tokens,
|
||||||
scanner_publication_commit_scope: None,
|
scanner_publication_commit_scope: None,
|
||||||
|
namespace_commit_guard: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3866,6 +3868,14 @@ impl<'a> RenameDataFenceOptions<'a> {
|
|||||||
self.scanner_publication_commit_scope = scanner_publication_commit_scope;
|
self.scanner_publication_commit_scope = scanner_publication_commit_scope;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(in crate::set_disk) fn with_namespace_commit_guard(
|
||||||
|
mut self,
|
||||||
|
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||||
|
) -> Self {
|
||||||
|
self.namespace_commit_guard = namespace_commit_guard;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||||
@@ -4224,6 +4234,7 @@ impl SetDisks {
|
|||||||
write_quorum,
|
write_quorum,
|
||||||
scanner_publication_lease_tokens,
|
scanner_publication_lease_tokens,
|
||||||
scanner_publication_commit_scope: _scanner_publication_commit_scope,
|
scanner_publication_commit_scope: _scanner_publication_commit_scope,
|
||||||
|
namespace_commit_guard,
|
||||||
} = fence_options;
|
} = fence_options;
|
||||||
if let Some(file_info) = disks
|
if let Some(file_info) = disks
|
||||||
.iter()
|
.iter()
|
||||||
@@ -4268,7 +4279,9 @@ impl SetDisks {
|
|||||||
let dst_object = fanout_dst_object.clone();
|
let dst_object = fanout_dst_object.clone();
|
||||||
let file_info = file_info.clone();
|
let file_info = file_info.clone();
|
||||||
let successful_rename_completion_rank = successful_rename_completion_rank.clone();
|
let successful_rename_completion_rank = successful_rename_completion_rank.clone();
|
||||||
|
let namespace_commit_guard = namespace_commit_guard.clone();
|
||||||
tasks.spawn(async move {
|
tasks.spawn(async move {
|
||||||
|
let _namespace_commit_guard = namespace_commit_guard;
|
||||||
let result = std::panic::AssertUnwindSafe(async move {
|
let result = std::panic::AssertUnwindSafe(async move {
|
||||||
#[allow(clippy::let_unit_value)]
|
#[allow(clippy::let_unit_value)]
|
||||||
let _fanout_task_guard = Self::rename_fanout_task_guard(&dst_object);
|
let _fanout_task_guard = Self::rename_fanout_task_guard(&dst_object);
|
||||||
@@ -4582,6 +4595,7 @@ impl SetDisks {
|
|||||||
write_quorum,
|
write_quorum,
|
||||||
scanner_publication_lease_tokens,
|
scanner_publication_lease_tokens,
|
||||||
scanner_publication_commit_scope,
|
scanner_publication_commit_scope,
|
||||||
|
namespace_commit_guard,
|
||||||
} = fence_options;
|
} = fence_options;
|
||||||
if let Some(file_info) = disks
|
if let Some(file_info) = disks
|
||||||
.iter()
|
.iter()
|
||||||
@@ -4614,6 +4628,7 @@ impl SetDisks {
|
|||||||
let fanout_dst_bucket = dst_bucket.clone();
|
let fanout_dst_bucket = dst_bucket.clone();
|
||||||
let fanout_dst_object = dst_object.clone();
|
let fanout_dst_object = dst_object.clone();
|
||||||
let fanout_publication_scope = scanner_publication_commit_scope.clone();
|
let fanout_publication_scope = scanner_publication_commit_scope.clone();
|
||||||
|
let fanout_namespace_commit_guard = namespace_commit_guard.clone();
|
||||||
// Keep one coordinator task so a cancelled caller cannot drop partially
|
// Keep one coordinator task so a cancelled caller cannot drop partially
|
||||||
// completed disk mutations. Per-disk futures stay ordered in `join_all`,
|
// completed disk mutations. Per-disk futures stay ordered in `join_all`,
|
||||||
// preserving slot-indexed quorum and convergence accounting without a
|
// preserving slot-indexed quorum and convergence accounting without a
|
||||||
@@ -4622,6 +4637,7 @@ impl SetDisks {
|
|||||||
// Keep the storage-owned movement permit attached to the actual
|
// Keep the storage-owned movement permit attached to the actual
|
||||||
// fan-out owner, even if the caller future is cancelled.
|
// fan-out owner, even if the caller future is cancelled.
|
||||||
let _fanout_publication_scope = fanout_publication_scope;
|
let _fanout_publication_scope = fanout_publication_scope;
|
||||||
|
let _namespace_commit_guard = fanout_namespace_commit_guard;
|
||||||
let successful_rename_completion_rank =
|
let successful_rename_completion_rank =
|
||||||
rustfs_io_metrics::put_stage_metrics_enabled().then(|| Arc::new(AtomicUsize::new(0)));
|
rustfs_io_metrics::put_stage_metrics_enabled().then(|| Arc::new(AtomicUsize::new(0)));
|
||||||
let futures = fanout_disks
|
let futures = fanout_disks
|
||||||
@@ -4807,6 +4823,8 @@ impl SetDisks {
|
|||||||
let dst_bucket = dst_bucket.clone();
|
let dst_bucket = dst_bucket.clone();
|
||||||
let dst_object = dst_object.clone();
|
let dst_object = dst_object.clone();
|
||||||
futures.push(tokio::spawn(async move {
|
futures.push(tokio::spawn(async move {
|
||||||
|
#[cfg(test)]
|
||||||
|
rename_fanout_barrier::checkpoint(&dst_object, i, rename_fanout_barrier::PHASE_ROLLBACK).await;
|
||||||
disk.delete_version(
|
disk.delete_version(
|
||||||
&dst_bucket,
|
&dst_bucket,
|
||||||
&dst_object,
|
&dst_object,
|
||||||
@@ -6565,9 +6583,9 @@ impl SetDisks {
|
|||||||
match oi {
|
match oi {
|
||||||
Ok(oi) => {
|
Ok(oi) => {
|
||||||
// Ordinary writes may proceed past a top-level delete marker;
|
// Ordinary writes may proceed past a top-level delete marker;
|
||||||
// data movement must not replace an acknowledged deletion.
|
// data movement and guarded internal writes must preserve it.
|
||||||
if oi.delete_marker {
|
if oi.delete_marker {
|
||||||
return opts.data_movement.then_some(StorageError::PreconditionFailed);
|
return (opts.data_movement || opts.preserve_delete_marker).then_some(StorageError::PreconditionFailed);
|
||||||
}
|
}
|
||||||
let if_none_match = http_preconditions.if_none_match_value().map(str::to_owned);
|
let if_none_match = http_preconditions.if_none_match_value().map(str::to_owned);
|
||||||
let if_match = http_preconditions.if_match_value().map(str::to_owned);
|
let if_match = http_preconditions.if_match_value().map(str::to_owned);
|
||||||
@@ -6960,6 +6978,7 @@ pub(crate) mod rename_fanout_barrier {
|
|||||||
pub use super::rename_fanout_barrier_phase::{
|
pub use super::rename_fanout_barrier_phase::{
|
||||||
CLEANUP as PHASE_CLEANUP, READ_VERSION as PHASE_READ_VERSION, RENAME as PHASE_RENAME,
|
CLEANUP as PHASE_CLEANUP, READ_VERSION as PHASE_READ_VERSION, RENAME as PHASE_RENAME,
|
||||||
};
|
};
|
||||||
|
pub const PHASE_ROLLBACK: &str = "rollback";
|
||||||
|
|
||||||
/// One armed barrier: the fan-out task matching `(disk_index, phase)` pauses.
|
/// One armed barrier: the fan-out task matching `(disk_index, phase)` pauses.
|
||||||
struct Armed {
|
struct Armed {
|
||||||
@@ -10534,9 +10553,35 @@ mod tests {
|
|||||||
let mut file_infos = rename_commit_fileinfos(object, DISKS, "fresh-rollback-etag");
|
let mut file_infos = rename_commit_fileinfos(object, DISKS, "fresh-rollback-etag");
|
||||||
file_infos[3] = FileInfo::default();
|
file_infos[3] = FileInfo::default();
|
||||||
|
|
||||||
SetDisks::rename_data(&disks, RUSTFS_META_TMP_BUCKET, "source", &file_infos, bucket, object, 4)
|
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
|
ctx.set_scanner_publication_state(false);
|
||||||
|
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_ROLLBACK);
|
||||||
|
let rename = SetDisks::rename_data_owned_with_fence(
|
||||||
|
&disks,
|
||||||
|
(RUSTFS_META_TMP_BUCKET, "source"),
|
||||||
|
file_infos,
|
||||||
|
(bucket, object),
|
||||||
|
false,
|
||||||
|
RenameDataFenceOptions::new(4, None).with_namespace_commit_guard(Some(ctx.begin_namespace_commit())),
|
||||||
|
);
|
||||||
|
let control = async {
|
||||||
|
barrier.wait_until_paused().await;
|
||||||
|
assert!(ctx.namespace_commits_pending(), "rollback must retain namespace publication ownership");
|
||||||
|
assert!(ctx.scanner_publication_state_allowed(), "rollback must not disable namespace walks");
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||||
|
barrier.release();
|
||||||
|
};
|
||||||
|
let (result, ()) = tokio::time::timeout(BARRIER_PAUSE_GUARD, async { tokio::join!(rename, control) })
|
||||||
.await
|
.await
|
||||||
.expect_err("three successful disks must fail a strict write quorum of four");
|
.expect("rename rollback must reach its barrier and finish after release");
|
||||||
|
assert_eq!(
|
||||||
|
result.err(),
|
||||||
|
Some(DiskError::ErasureWriteQuorum),
|
||||||
|
"three successful disks must fail a strict write quorum of four"
|
||||||
|
);
|
||||||
|
assert!(!ctx.namespace_commits_pending());
|
||||||
|
assert!(ctx.scanner_publication_state_allowed());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||||
|
|
||||||
for (idx, dir) in dirs.iter().enumerate() {
|
for (idx, dir) in dirs.iter().enumerate() {
|
||||||
let reopened = reopen_local_disk(dir).await;
|
let reopened = reopen_local_disk(dir).await;
|
||||||
|
|||||||
@@ -4051,6 +4051,7 @@ mod tests {
|
|||||||
let _ = drain_global_dirty_scopes();
|
let _ = drain_global_dirty_scopes();
|
||||||
|
|
||||||
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let rename_tasks = rename_fanout_barrier::observe_tasks(object);
|
||||||
let complete_store = Arc::clone(&set_disks);
|
let complete_store = Arc::clone(&set_disks);
|
||||||
let mut complete = tokio::spawn(async move {
|
let mut complete = tokio::spawn(async move {
|
||||||
let mut opts = ObjectOptions::default();
|
let mut opts = ObjectOptions::default();
|
||||||
@@ -4062,16 +4063,6 @@ mod tests {
|
|||||||
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
|
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
|
||||||
.await
|
.await
|
||||||
.expect("multipart completion should pause one tail disk during rename");
|
.expect("multipart completion should pause one tail disk during rename");
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_millis(100), &mut complete).await.is_err(),
|
|
||||||
"multipart completion must not publish success while a tail rename is still paused"
|
|
||||||
);
|
|
||||||
|
|
||||||
let initial = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
|
||||||
assert!(
|
|
||||||
initial.is_empty(),
|
|
||||||
"capacity must not be marked as committed before the full multipart rename finishes"
|
|
||||||
);
|
|
||||||
|
|
||||||
let abort_store = Arc::clone(&set_disks);
|
let abort_store = Arc::clone(&set_disks);
|
||||||
let abort = tokio::spawn(async move {
|
let abort = tokio::spawn(async move {
|
||||||
@@ -4080,21 +4071,46 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
signaling.wait_for_attempts(2).await;
|
signaling.wait_for_attempts(2).await;
|
||||||
assert!(!abort.is_finished(), "the in-flight completion must retain the multipart upload guard");
|
|
||||||
|
|
||||||
let retained_staging = futures::future::join_all(
|
// A paused rename does not establish that the other disks reached quorum.
|
||||||
disk_stores
|
let retained_staging = tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
.iter()
|
loop {
|
||||||
.map(|disk| disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &staged_part)),
|
let mut retained = 0;
|
||||||
)
|
for result in futures::future::join_all(
|
||||||
|
disk_stores
|
||||||
|
.iter()
|
||||||
|
.map(|disk| disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &staged_part)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
match result {
|
||||||
|
Ok(_) => retained += 1,
|
||||||
|
Err(DiskError::FileNotFound) => {}
|
||||||
|
Err(error) => panic!("staged rename source lookup failed: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if retained <= 1 && rename_tasks.running() == 1 {
|
||||||
|
break retained;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.expect("unpaused multipart renames should finish before the tail is released");
|
||||||
.filter(|result| result.is_ok())
|
|
||||||
.count();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
retained_staging, 1,
|
retained_staging, 1,
|
||||||
"only the paused tail disk should still retain the multipart rename source"
|
"only the paused tail disk should still retain the multipart rename source"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), &mut complete).await.is_err(),
|
||||||
|
"multipart completion must not publish success while a tail rename is still paused"
|
||||||
|
);
|
||||||
|
let initial = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||||
|
assert!(
|
||||||
|
initial.is_empty(),
|
||||||
|
"capacity must not be marked as committed before the full multipart rename finishes"
|
||||||
|
);
|
||||||
|
assert!(!abort.is_finished(), "the in-flight completion must retain the multipart upload guard");
|
||||||
|
|
||||||
signaling.set_target(rustfs_lock::ObjectKey::new(bucket, object));
|
signaling.set_target(rustfs_lock::ObjectKey::new(bucket, object));
|
||||||
let object_attempt = signaling.attempts.load(Ordering::Acquire) + 1;
|
let object_attempt = signaling.attempts.load(Ordering::Acquire) + 1;
|
||||||
|
|||||||
@@ -4452,7 +4452,10 @@ impl SetDisks {
|
|||||||
write_quorum,
|
write_quorum,
|
||||||
commit_scanner_publication_lease_tokens.as_ref(),
|
commit_scanner_publication_lease_tokens.as_ref(),
|
||||||
)
|
)
|
||||||
.with_publication_scope(commit_scanner_publication_scope.clone()),
|
.with_publication_scope(commit_scanner_publication_scope.clone())
|
||||||
|
.with_namespace_commit_guard(
|
||||||
|
(!is_meta_bucketname(&commit_bucket)).then(|| commit_set.ctx.begin_namespace_commit()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Some(scope) = commit_scanner_publication_scope.as_ref() {
|
if let Some(scope) = commit_scanner_publication_scope.as_ref() {
|
||||||
|
|||||||
@@ -1059,6 +1059,7 @@ mod tests {
|
|||||||
use crate::storage_api_contracts::{
|
use crate::storage_api_contracts::{
|
||||||
bucket::{BucketOperations as _, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
|
bucket::{BucketOperations as _, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
|
||||||
list::ListOperations as _,
|
list::ListOperations as _,
|
||||||
|
namespace::NamespaceLocking as _,
|
||||||
object::{ObjectIO as _, ObjectOperations as _},
|
object::{ObjectIO as _, ObjectOperations as _},
|
||||||
};
|
};
|
||||||
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
|
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
|
||||||
@@ -1486,10 +1487,19 @@ mod tests {
|
|||||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||||
.await
|
.await
|
||||||
.expect("object should be written");
|
.expect("object should be written");
|
||||||
|
let lock = ecstore.pools[0].disk_set[0]
|
||||||
|
.new_ns_lock(bucket, object)
|
||||||
|
.await
|
||||||
|
.expect("fixture namespace lock should be created");
|
||||||
|
drop(
|
||||||
|
lock.get_write_lock(Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("fixture rename tail should finish before checking its generation"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ecstore.scanner_namespace_mutation_generation(),
|
ecstore.scanner_namespace_mutation_generation(),
|
||||||
generation_before_put.saturating_add(1),
|
generation_before_put.saturating_add(3),
|
||||||
"successful object creation should advance scanner namespace activity"
|
"successful object creation must observe the logical mutation and both fanout boundaries"
|
||||||
);
|
);
|
||||||
ecstore
|
ecstore
|
||||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||||
|
|||||||
@@ -787,6 +787,12 @@ impl ECStore {
|
|||||||
pub fn single_pool(&self) -> bool {
|
pub fn single_pool(&self) -> bool {
|
||||||
self.pools.len() == 1
|
self.pools.len() == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The set-local create-only check is atomic only when every object
|
||||||
|
/// mutation uses that same, enabled namespace lock domain.
|
||||||
|
pub fn supports_atomic_create_only_write_back(&self) -> bool {
|
||||||
|
!self.ctx.lock_manager().is_disabled() && self.pools.len() == 1 && self.pools[0].disk_set.len() == 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -2127,7 +2133,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|&drives_per_set| (1, drives_per_set))
|
.map(|&drives_per_set| (1, drives_per_set))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
build_isolated_test_store_with_layout(temp_dir, cmd_line, &pool_layouts, shutdown).await
|
build_isolated_test_store_with_layout(temp_dir, cmd_line, &pool_layouts, shutdown, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_isolated_test_store_with_layout(
|
async fn build_isolated_test_store_with_layout(
|
||||||
@@ -2135,6 +2141,7 @@ mod tests {
|
|||||||
cmd_line: &str,
|
cmd_line: &str,
|
||||||
pool_layouts: &[(usize, usize)],
|
pool_layouts: &[(usize, usize)],
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
|
instance_ctx: Option<Arc<crate::runtime::instance::InstanceContext>>,
|
||||||
) -> (
|
) -> (
|
||||||
Arc<crate::runtime::instance::InstanceContext>,
|
Arc<crate::runtime::instance::InstanceContext>,
|
||||||
Arc<crate::store::ECStore>,
|
Arc<crate::store::ECStore>,
|
||||||
@@ -2167,7 +2174,7 @@ mod tests {
|
|||||||
let endpoint_pools = EndpointServerPools(pools);
|
let endpoint_pools = EndpointServerPools(pools);
|
||||||
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
||||||
|
|
||||||
let instance_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
let instance_ctx = instance_ctx.unwrap_or_else(|| Arc::new(crate::runtime::instance::InstanceContext::new()));
|
||||||
crate::store::init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
crate::store::init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||||
.await
|
.await
|
||||||
.expect("register local disks into the fresh context");
|
.expect("register local disks into the fresh context");
|
||||||
@@ -2535,6 +2542,348 @@ mod tests {
|
|||||||
shutdown.cancel();
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn early_ack_put_tails_block_scanner_publication_until_all_renames_finish() {
|
||||||
|
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().expect("create scanner PUT tail store dir");
|
||||||
|
let (ctx, store, shutdown) =
|
||||||
|
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "scanner-put-tails", &[4])).await;
|
||||||
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||||
|
let bucket = format!("scanner-put-tails-{}", Uuid::new_v4());
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create scanner PUT tail bucket");
|
||||||
|
let set = &store.pools[0].disk_set[0];
|
||||||
|
let objects = [("scanner-tail-a", vec![0xA1; 273]), ("scanner-tail-b", vec![0xB2; 379])];
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||||
|
let (active, blocked, movement_generation) = store.scanner_data_movement_activity().await;
|
||||||
|
assert!(!active && !blocked);
|
||||||
|
assert!(ctx.scanner_publication_state_allowed(), "the set admission cache should start allowed");
|
||||||
|
let (old_lease, _) = store
|
||||||
|
.acquire_scanner_publication_lease(movement_generation, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||||
|
.await
|
||||||
|
.expect("publication lease should be admitted before either PUT starts");
|
||||||
|
|
||||||
|
let barriers: Vec<_> = objects
|
||||||
|
.iter()
|
||||||
|
.map(|(object, _)| {
|
||||||
|
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trackers: Vec<_> = objects
|
||||||
|
.iter()
|
||||||
|
.map(|(object, _)| crate::set_disk::rename_fanout_barrier::observe_tasks(object))
|
||||||
|
.collect();
|
||||||
|
let puts: Vec<_> = objects
|
||||||
|
.iter()
|
||||||
|
.map(|(object, body)| {
|
||||||
|
let put_store = Arc::clone(&store);
|
||||||
|
let put_bucket = bucket.clone();
|
||||||
|
let object = *object;
|
||||||
|
let body = body.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut reader = PutObjReader::from_vec(body);
|
||||||
|
put_store
|
||||||
|
.put_object(&put_bucket, object, &mut reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let committed = tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
for barrier in &barriers {
|
||||||
|
barrier.wait_until_paused().await;
|
||||||
|
}
|
||||||
|
let mut committed = Vec::with_capacity(puts.len());
|
||||||
|
for put in puts {
|
||||||
|
committed.push(
|
||||||
|
put.await
|
||||||
|
.expect("early-ACK PUT task should join while its tail is paused")
|
||||||
|
.expect("root PUT should return after quorum without waiting for its tail"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
committed
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("both root PUTs must quorum-ACK while their tail disks remain paused");
|
||||||
|
|
||||||
|
assert!(trackers.iter().all(|tracker| tracker.running() >= 1));
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert!(
|
||||||
|
ctx.scanner_publication_state_allowed(),
|
||||||
|
"pending PUT tails must not disable scanner namespace walks"
|
||||||
|
);
|
||||||
|
let (active, blocked, observed_movement_generation) = store.scanner_data_movement_activity().await;
|
||||||
|
assert!(!active, "ordinary PUT tails are not decommission or rebalance work");
|
||||||
|
assert!(!blocked, "ordinary PUT tails must not block the movement-only scan baseline");
|
||||||
|
assert_eq!(observed_movement_generation, movement_generation);
|
||||||
|
assert!(store.scanner_data_usage_publication_blocked().await);
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
for error in [
|
||||||
|
store
|
||||||
|
.acquire_scanner_publication_lease(
|
||||||
|
movement_generation,
|
||||||
|
crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a new remote publication lease must reject pending PUT tails"),
|
||||||
|
store
|
||||||
|
.validate_scanner_publication_lease(old_lease, movement_generation)
|
||||||
|
.await
|
||||||
|
.expect_err("an existing remote lease must not bypass pending PUT tails"),
|
||||||
|
store
|
||||||
|
.acquire_scanner_publication_lease_guard(old_lease)
|
||||||
|
.await
|
||||||
|
.expect_err("target-side publication admission must reject pending PUT tails"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("blocked"),
|
||||||
|
"publication must fail because of active tails: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
store.release_scanner_publication_lease(old_lease).await;
|
||||||
|
|
||||||
|
for (index, barrier) in barriers.iter().enumerate() {
|
||||||
|
let commit_generation = ctx.namespace_commit_generation();
|
||||||
|
let namespace_generation = store.scanner_namespace_mutation_generation();
|
||||||
|
barrier.release();
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
while trackers[index].running() != 0 || ctx.namespace_commit_generation() <= commit_generation {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
if index + 1 == barriers.len() {
|
||||||
|
while ctx.namespace_commits_pending() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("released tail must drain and publish its terminal namespace generation");
|
||||||
|
assert!(store.scanner_namespace_mutation_generation() > namespace_generation);
|
||||||
|
let pending = index + 1 < barriers.len();
|
||||||
|
assert_eq!(ctx.namespace_commits_pending(), pending);
|
||||||
|
assert_eq!(store.scanner_data_usage_publication_blocked().await, pending);
|
||||||
|
assert!(!store.scanner_data_movement_activity().await.1);
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (lease, generation) = store
|
||||||
|
.acquire_scanner_publication_lease(movement_generation, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||||
|
.await
|
||||||
|
.expect("remote publication lease should resume after both tails drain");
|
||||||
|
store
|
||||||
|
.validate_scanner_publication_lease(lease, generation)
|
||||||
|
.await
|
||||||
|
.expect("a resumed remote publication lease should validate");
|
||||||
|
drop(
|
||||||
|
store
|
||||||
|
.acquire_scanner_publication_lease_guard(lease)
|
||||||
|
.await
|
||||||
|
.expect("target-side publication admission should resume after both tails drain"),
|
||||||
|
);
|
||||||
|
assert!(store.release_scanner_publication_lease(lease).await);
|
||||||
|
|
||||||
|
let disks = set.disk_inventory().await;
|
||||||
|
assert_eq!(disks.len(), 4);
|
||||||
|
for ((object, body), committed) in objects.iter().zip(&committed) {
|
||||||
|
let logical_size = i64::try_from(body.len()).expect("fixture payload size should fit i64");
|
||||||
|
let etag = committed.etag.as_ref().expect("root PUT should return a committed ETag");
|
||||||
|
for (disk_index, disk) in disks.iter().enumerate() {
|
||||||
|
let file_info = disk
|
||||||
|
.as_ref()
|
||||||
|
.expect("every fixture disk should remain online")
|
||||||
|
.read_version(
|
||||||
|
"",
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
"",
|
||||||
|
&crate::disk::ReadOptions {
|
||||||
|
read_data: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| panic!("disk {disk_index} should publish {object} after its tail finishes: {err}"));
|
||||||
|
assert_eq!(file_info.size, logical_size);
|
||||||
|
assert_eq!(file_info.metadata.get(http::header::ETAG.as_str()), Some(etag));
|
||||||
|
assert!(
|
||||||
|
file_info.inline_data(),
|
||||||
|
"small fixture payloads should have an inline shard on every disk"
|
||||||
|
);
|
||||||
|
let inline_data = file_info.data.as_ref().expect("every disk should retain its inline shard");
|
||||||
|
let erasure = crate::erasure::coding::Erasure::try_new_with_options(
|
||||||
|
file_info.erasure.data_blocks,
|
||||||
|
file_info.erasure.parity_blocks,
|
||||||
|
file_info.erasure.block_size,
|
||||||
|
file_info.uses_legacy_checksum,
|
||||||
|
)
|
||||||
|
.expect("persisted erasure geometry should be valid");
|
||||||
|
let shard_size =
|
||||||
|
usize::try_from(erasure.shard_file_size(logical_size)).expect("fixture shard size should fit usize");
|
||||||
|
crate::erasure::coding::bitrot_verify(
|
||||||
|
Cursor::new(inline_data.clone()),
|
||||||
|
inline_data.len(),
|
||||||
|
shard_size,
|
||||||
|
rustfs_utils::HashAlgorithm::HighwayHash256S,
|
||||||
|
erasure.shard_size(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| panic!("disk {disk_index} should retain a complete valid shard for {object}: {err}"));
|
||||||
|
}
|
||||||
|
let mut reader = store
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("fully drained PUT should be readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader.stream.read_to_end(&mut actual).await.expect("PUT body should drain");
|
||||||
|
assert_eq!(&actual, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let generation_before_internal_put = ctx.namespace_commit_generation();
|
||||||
|
let internal_object = "scanner-tail-regression/internal-metadata";
|
||||||
|
let internal_body = b"scanner metadata must not invalidate its own publication";
|
||||||
|
let mut internal_reader = PutObjReader::from_vec(internal_body.to_vec());
|
||||||
|
store
|
||||||
|
.put_object(RUSTFS_META_BUCKET, internal_object, &mut internal_reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("internal metadata PUT should commit without scanner self-invalidation");
|
||||||
|
let internal_lock = set
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, internal_object)
|
||||||
|
.await
|
||||||
|
.expect("internal metadata tail lock should be available");
|
||||||
|
drop(
|
||||||
|
internal_lock
|
||||||
|
.get_write_lock(Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("internal metadata tail should drain"),
|
||||||
|
);
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), generation_before_internal_put);
|
||||||
|
assert!(!ctx.namespace_commits_pending());
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
let mut internal_reader = store
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, internal_object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("internal metadata should remain readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
internal_reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("internal metadata body should drain");
|
||||||
|
assert_eq!(actual, internal_body);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
shutdown.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn cancelled_early_ack_put_keeps_scanner_publication_blocked_until_tail_finishes() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("create cancelled scanner PUT tail store dir");
|
||||||
|
let (ctx, store, shutdown) =
|
||||||
|
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "scanner-cancelled-put-tail", &[4])).await;
|
||||||
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||||
|
let bucket = format!("scanner-cancelled-put-tail-{}", Uuid::new_v4());
|
||||||
|
let object = "scanner-cancelled-tail";
|
||||||
|
let body = vec![0xC3; 273];
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create cancelled scanner PUT tail bucket");
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||||
|
let tracker = crate::set_disk::rename_fanout_barrier::observe_tasks(object);
|
||||||
|
let tail =
|
||||||
|
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let quorum = crate::set_disk::PutObjectCommitBarrier::install(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
crate::set_disk::PutObjectCommitPause::AfterRenameQuorum,
|
||||||
|
);
|
||||||
|
let handoff = crate::set_disk::PutObjectCommitBarrier::install(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
crate::set_disk::PutObjectCommitPause::AfterRenameHandoff,
|
||||||
|
);
|
||||||
|
let put_store = Arc::clone(&store);
|
||||||
|
let put_bucket = bucket.clone();
|
||||||
|
let put_body = body.clone();
|
||||||
|
let put = tokio::spawn(async move {
|
||||||
|
let mut reader = PutObjReader::from_vec(put_body);
|
||||||
|
put_store
|
||||||
|
.put_object(&put_bucket, object, &mut reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), tail.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("cancelled PUT should pause one disk before rename");
|
||||||
|
quorum.wait_until_paused().await;
|
||||||
|
put.abort();
|
||||||
|
assert!(
|
||||||
|
put.await
|
||||||
|
.expect_err("caller should be cancelled after rename quorum")
|
||||||
|
.is_cancelled()
|
||||||
|
);
|
||||||
|
quorum.release();
|
||||||
|
handoff.wait_until_paused().await;
|
||||||
|
assert!(tracker.running() >= 1);
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert!(!store.scanner_data_movement_activity().await.1);
|
||||||
|
assert!(store.scanner_data_usage_publication_blocked().await);
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
assert!(
|
||||||
|
store.pools[0].disk_set[0]
|
||||||
|
.scanner_data_usage_publication_admission_guard()
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
let generation = store.scanner_namespace_mutation_generation();
|
||||||
|
|
||||||
|
handoff.release();
|
||||||
|
tail.release();
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
while tracker.running() != 0 || ctx.namespace_commits_pending() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("cancelled request's detached fanout must release scanner admission after finishing");
|
||||||
|
assert!(store.scanner_namespace_mutation_generation() > generation);
|
||||||
|
assert!(!store.scanner_data_usage_publication_blocked().await);
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
for (disk_index, disk) in store.pools[0].disk_set[0].disk_inventory().await.iter().enumerate() {
|
||||||
|
let file_info = disk
|
||||||
|
.as_ref()
|
||||||
|
.expect("cancelled PUT fixture disk should remain online")
|
||||||
|
.read_version("", &bucket, object, "", &crate::disk::ReadOptions::default())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| panic!("cancelled PUT must still publish on disk {disk_index}: {err}"));
|
||||||
|
assert_eq!(file_info.size, i64::try_from(body.len()).expect("fixture body size should fit i64"));
|
||||||
|
}
|
||||||
|
let mut reader = store
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("a cancelled caller must not discard its quorum-committed object");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("cancelled PUT body should drain");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
shutdown.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
#[test]
|
#[test]
|
||||||
#[serial_test::serial(storage_class_env)]
|
#[serial_test::serial(storage_class_env)]
|
||||||
@@ -2979,6 +3328,43 @@ mod tests {
|
|||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
const DECOMMISSION_TEST_FAULT_STAGE_TIERED: &str = "decommission_tiered_object";
|
const DECOMMISSION_TEST_FAULT_STAGE_TIERED: &str = "decommission_tiered_object";
|
||||||
|
|
||||||
|
fn inject_decommission_copy_fault(faults: &AtomicUsize, attempt: usize, succeeded: bool) -> bool {
|
||||||
|
// Entry retries reset attempt, not the global post-commit fault budget.
|
||||||
|
// A real failure may consume an attempt, so preserve the final chance.
|
||||||
|
succeeded
|
||||||
|
&& faults
|
||||||
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |faults| {
|
||||||
|
(faults < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS.saturating_sub(1)
|
||||||
|
&& attempt < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS)
|
||||||
|
.then_some(faults.saturating_add(1))
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decommission_copy_fault_budget_survives_entry_restarts_and_preserves_last_attempt() {
|
||||||
|
let cases: &[&[(usize, bool, bool)]] = &[
|
||||||
|
&[(1, true, true), (2, true, true), (3, true, false)],
|
||||||
|
&[(1, true, true), (2, false, false), (1, true, true), (2, true, false)],
|
||||||
|
&[(1, true, true), (2, false, false), (3, true, false)],
|
||||||
|
&[(1, true, true), (1, true, true), (1, true, false)],
|
||||||
|
&[(3, true, false), (4, true, false)],
|
||||||
|
];
|
||||||
|
for case in cases {
|
||||||
|
let faults = AtomicUsize::new(0);
|
||||||
|
let mut expected_faults = 0;
|
||||||
|
for &(attempt, succeeded, expected) in *case {
|
||||||
|
assert_eq!(
|
||||||
|
inject_decommission_copy_fault(&faults, attempt, succeeded),
|
||||||
|
expected,
|
||||||
|
"fault plan {case:?} at attempt {attempt}"
|
||||||
|
);
|
||||||
|
expected_faults += usize::from(expected);
|
||||||
|
assert_eq!(faults.load(Ordering::SeqCst), expected_faults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn seed_decommission_source(
|
async fn seed_decommission_source(
|
||||||
store: &Arc<crate::store::ECStore>,
|
store: &Arc<crate::store::ECStore>,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -4991,6 +5377,7 @@ mod tests {
|
|||||||
"decommission-delete-fence",
|
"decommission-delete-fence",
|
||||||
&[(2, 4), (1, 4)],
|
&[(2, 4), (1, 4)],
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -5218,25 +5605,10 @@ mod tests {
|
|||||||
let fault_bucket = other_bucket.clone();
|
let fault_bucket = other_bucket.clone();
|
||||||
let _fault_guard = crate::core::pools::DecommissionTestFaultGuard::install(Arc::new(
|
let _fault_guard = crate::core::pools::DecommissionTestFaultGuard::install(Arc::new(
|
||||||
move |stage, bucket, object, attempt, succeeded| {
|
move |stage, bucket, object, attempt, succeeded| {
|
||||||
let candidate = succeeded
|
let candidate = stage == DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT
|
||||||
&& stage == DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT
|
|
||||||
&& bucket == fault_bucket.as_str()
|
&& bucket == fault_bucket.as_str()
|
||||||
&& object == other_object;
|
&& object == other_object;
|
||||||
if !candidate {
|
candidate && inject_decommission_copy_fault(&ordinary_faults_for_hook, attempt, succeeded)
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the fault budget global across any
|
|
||||||
// entry-level re-list; its inner attempt counter
|
|
||||||
// restarts after SourceChanged.
|
|
||||||
ordinary_faults_for_hook
|
|
||||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |faults| {
|
|
||||||
let next_fault = faults.saturating_add(1);
|
|
||||||
(faults < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS.saturating_sub(1)
|
|
||||||
&& attempt == next_fault)
|
|
||||||
.then_some(next_fault)
|
|
||||||
})
|
|
||||||
.is_ok()
|
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -5275,6 +5647,15 @@ mod tests {
|
|||||||
changed_result.expect("SourceChanged entry retry must converge");
|
changed_result.expect("SourceChanged entry retry must converge");
|
||||||
other_result.expect("other bucket entry must continue through ordinary copy retries");
|
other_result.expect("other bucket entry must continue through ordinary copy retries");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.pool_meta.read().await.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("decommission progress should remain available")
|
||||||
|
.items_decommission_failed,
|
||||||
|
0,
|
||||||
|
"entry completion must not hide an exhausted copy failure"
|
||||||
|
);
|
||||||
assert!(!rx.is_cancelled(), "entry-level SourceChanged must not cancel the shared worker token");
|
assert!(!rx.is_cancelled(), "entry-level SourceChanged must not cancel the shared worker token");
|
||||||
assert_eq!(mutation_calls.load(Ordering::SeqCst), 2, "entry must be re-listed after SourceChanged");
|
assert_eq!(mutation_calls.load(Ordering::SeqCst), 2, "entry must be re-listed after SourceChanged");
|
||||||
assert_eq!(ordinary_faults.load(Ordering::SeqCst), 2, "ordinary copy must consume the retry budget");
|
assert_eq!(ordinary_faults.load(Ordering::SeqCst), 2, "ordinary copy must consume the retry budget");
|
||||||
@@ -5903,6 +6284,7 @@ mod tests {
|
|||||||
"reverse-decommission-fixed-target",
|
"reverse-decommission-fixed-target",
|
||||||
&[(1, 4), (1, 4)],
|
&[(1, 4), (1, 4)],
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -6324,6 +6706,7 @@ mod tests {
|
|||||||
"multi-set-decommission-source-cleanup",
|
"multi-set-decommission-source-cleanup",
|
||||||
&[(2, 4)],
|
&[(2, 4)],
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -8834,18 +9217,17 @@ mod tests {
|
|||||||
const MANIFEST_COUNT: usize = 10;
|
const MANIFEST_COUNT: usize = 10;
|
||||||
|
|
||||||
let temp_dir = tempfile::tempdir().expect("create fast manifest pass recovery store dir");
|
let temp_dir = tempfile::tempdir().expect("create fast manifest pass recovery store dir");
|
||||||
let (ctx, store, _shutdown) =
|
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-fast-manifest-pass", &[4])).await;
|
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||||
|
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||||
|
temp_dir.path(),
|
||||||
|
"tier-delete-fast-manifest-pass",
|
||||||
|
&[(1, 4)],
|
||||||
|
CancellationToken::new(),
|
||||||
|
Some(Arc::new(instance_ctx)),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
let bucket = "tier-delete-fast-manifest-pass-bucket";
|
|
||||||
store
|
|
||||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("fast manifest pass bucket should be created");
|
|
||||||
let incarnation = store
|
|
||||||
.bucket_incarnation_id(bucket)
|
|
||||||
.await
|
|
||||||
.expect("fast manifest pass bucket incarnation should resolve");
|
|
||||||
let tier_name = "FAST-MANIFEST-PASS";
|
let tier_name = "FAST-MANIFEST-PASS";
|
||||||
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||||
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
||||||
@@ -8853,9 +9235,19 @@ mod tests {
|
|||||||
.expect("fast manifest pass tier lease should resolve")
|
.expect("fast manifest pass tier lease should resolve")
|
||||||
.backend_identity();
|
.backend_identity();
|
||||||
for index in 0..MANIFEST_COUNT {
|
for index in 0..MANIFEST_COUNT {
|
||||||
|
// Pagination must not depend on same-bucket lock wait deadlines.
|
||||||
|
let bucket = format!("tier-delete-fast-manifest-pass-{index}");
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("fast manifest pass bucket should be created");
|
||||||
|
let incarnation = store
|
||||||
|
.bucket_incarnation_id(&bucket)
|
||||||
|
.await
|
||||||
|
.expect("fast manifest pass bucket incarnation should resolve");
|
||||||
install_aborting_dispatch_fixture(
|
install_aborting_dispatch_fixture(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
bucket,
|
&bucket,
|
||||||
incarnation,
|
incarnation,
|
||||||
&format!("manifest-page-{index:06}/"),
|
&format!("manifest-page-{index:06}/"),
|
||||||
tier_name,
|
tier_name,
|
||||||
@@ -8886,12 +9278,78 @@ mod tests {
|
|||||||
"one production pass must cross the default eight-manifest page limit"
|
"one production pass must cross the default eight-manifest page limit"
|
||||||
);
|
);
|
||||||
assert_eq!(stats.manifests.scanned, MANIFEST_COUNT);
|
assert_eq!(stats.manifests.scanned, MANIFEST_COUNT);
|
||||||
assert_eq!(stats.manifests.deleted, MANIFEST_COUNT);
|
assert_eq!(stats.manifests.deleted, MANIFEST_COUNT, "full recovery result: {stats:?}");
|
||||||
assert_eq!(stats.manifests.failed, 0);
|
assert_eq!(stats.manifests.failed, 0, "full recovery result: {stats:?}");
|
||||||
assert_eq!(manifest_marker, None);
|
assert_eq!(manifest_marker, None);
|
||||||
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
||||||
assert_eq!(tier_delete_journal_count(store).await, 0);
|
assert_eq!(tier_delete_journal_count(store).await, 0);
|
||||||
assert_eq!(backend.remove_count().await, 0, "rollback recovery must not call the remote tier");
|
assert_eq!(backend.remove_count().await, 0, "rollback recovery must not call the remote tier");
|
||||||
|
shutdown.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn tier_delete_manual_pass_retains_manifest_owned_by_startup_recovery() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("create automatic recovery ownership store dir");
|
||||||
|
let (ctx, store, shutdown) =
|
||||||
|
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-auto-owner", &[4])).await;
|
||||||
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
let bucket = "tier-delete-auto-owner-bucket";
|
||||||
|
store
|
||||||
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("automatic recovery bucket should be created");
|
||||||
|
let incarnation = store.bucket_incarnation_id(bucket).await.expect("bucket incarnation");
|
||||||
|
let tier_name = "AUTO-OWNER";
|
||||||
|
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||||
|
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
||||||
|
.await
|
||||||
|
.expect("automatic recovery tier lease")
|
||||||
|
.backend_identity();
|
||||||
|
|
||||||
|
// The automatic worker must not observe a partially installed fixture.
|
||||||
|
let lifecycle_guard = store
|
||||||
|
.acquire_bucket_lifecycle_write_lock(bucket)
|
||||||
|
.await
|
||||||
|
.expect("fixture lifecycle lock");
|
||||||
|
let (manifest_name, entries) =
|
||||||
|
install_aborting_dispatch_fixture(store.clone(), bucket, incarnation, "auto-owner/", tier_name, identity, 1).await;
|
||||||
|
let journal_name = tier_delete_journal_object_name(&entries[0]);
|
||||||
|
let hook = TierDeleteDispatchRollbackTestHook::install_slow_delete(&journal_name, &journal_name);
|
||||||
|
drop(lifecycle_guard);
|
||||||
|
ctx.wake_tier_delete_journal_recovery();
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_delete_paused())
|
||||||
|
.await
|
||||||
|
.expect("startup recovery should own the manifest before a manual pass");
|
||||||
|
assert!(tier_delete_dispatch_manifest_recovery_inflight_for_test(&store, &manifest_name));
|
||||||
|
|
||||||
|
let stats = recover_tier_delete_dispatch_manifests(store.clone(), 8, None)
|
||||||
|
.await
|
||||||
|
.expect("manual recovery scan");
|
||||||
|
assert_eq!(stats.scanned, 1, "{stats:?}");
|
||||||
|
assert_eq!(stats.retained, 1, "{stats:?}");
|
||||||
|
assert_eq!(stats.deleted, 0, "{stats:?}");
|
||||||
|
assert_eq!(stats.failed, 0, "{stats:?}");
|
||||||
|
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 1);
|
||||||
|
assert_eq!(tier_delete_journal_count(store.clone()).await, 1);
|
||||||
|
|
||||||
|
hook.release_delete();
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
loop {
|
||||||
|
let manifest_gone = matches!(com::read_config(store.clone(), &manifest_name).await, Err(Error::ConfigNotFound));
|
||||||
|
if manifest_gone && !tier_delete_dispatch_manifest_recovery_inflight_for_test(&store, &manifest_name) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("automatic recovery should converge without a manual retry");
|
||||||
|
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
||||||
|
assert_eq!(tier_delete_journal_count(store).await, 0);
|
||||||
|
assert_eq!(backend.remove_count().await, 0, "rollback must not delete from the remote tier");
|
||||||
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
@@ -13016,6 +13474,7 @@ mod tests {
|
|||||||
"partial-set-prefix-delete",
|
"partial-set-prefix-delete",
|
||||||
&[(2, 4)],
|
&[(2, 4)],
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -16388,6 +16847,7 @@ mod tests {
|
|||||||
"prepared-directory-recovery",
|
"prepared-directory-recovery",
|
||||||
&[(2, 4)],
|
&[(2, 4)],
|
||||||
shutdown,
|
shutdown,
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -17036,6 +17496,38 @@ mod tests {
|
|||||||
.expect("test thread should complete");
|
.expect("test thread should complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn odm_write_back_requires_one_set_and_enabled_namespace_locking() {
|
||||||
|
for (layout, locking, supported) in [
|
||||||
|
(&[(1, 4)][..], true, true),
|
||||||
|
(&[(1, 4), (1, 4)][..], true, false),
|
||||||
|
(&[(2, 4)][..], true, false),
|
||||||
|
(&[(1, 4)][..], false, false),
|
||||||
|
] {
|
||||||
|
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some(if locking { "true" } else { "false" }))], async {
|
||||||
|
let dir = tempfile::tempdir().expect("isolated topology");
|
||||||
|
let shutdown = CancellationToken::new();
|
||||||
|
let (_ctx, store, _) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||||
|
dir.path(),
|
||||||
|
"odm-topology",
|
||||||
|
layout,
|
||||||
|
shutdown.clone(),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
store.supports_atomic_create_only_write_back(),
|
||||||
|
supported,
|
||||||
|
"layout={layout:?}, locking={locking}"
|
||||||
|
);
|
||||||
|
shutdown.cancel();
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial(storage_class_env)]
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
|||||||
@@ -848,7 +848,7 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn scanner_namespace_mutation_generation(&self) -> u64 {
|
pub fn scanner_namespace_mutation_generation(&self) -> u64 {
|
||||||
list_objects::scanner_namespace_mutation_generation()
|
list_objects::scanner_namespace_mutation_generation().saturating_add(self.ctx.namespace_commit_generation())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scanner_data_movement_active(&self) -> bool {
|
pub async fn scanner_data_movement_active(&self) -> bool {
|
||||||
@@ -857,7 +857,7 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return the storage-owned movement state and generation as one
|
/// Return the storage-owned movement state and generation as one
|
||||||
/// authenticated activity snapshot. The read lock is acquired before
|
/// authenticated activity snapshot. The read lock is acquired before
|
||||||
/// the state locks (cancelers, pool metadata, then rebalance metadata),
|
/// the state locks (cancelers, pool metadata, then rebalance metadata),
|
||||||
/// matching the transition writer order and preventing a terminal state
|
/// matching the transition writer order and preventing a terminal state
|
||||||
/// from being reported with the preceding generation.
|
/// from being reported with the preceding generation.
|
||||||
@@ -886,11 +886,12 @@ impl ECStore {
|
|||||||
/// Returns whether scanner metadata may still be hidden by a local
|
/// Returns whether scanner metadata may still be hidden by a local
|
||||||
/// data-movement state. Terminal failed/canceled decommission entries
|
/// data-movement state. Terminal failed/canceled decommission entries
|
||||||
/// remain suspended until an operator clears or retries them, so they are
|
/// remain suspended until an operator clears or retries them, so they are
|
||||||
/// a publication barrier even after the worker has stopped.
|
/// a publication barrier even after the worker has stopped. Active PUT
|
||||||
|
/// rename fanouts also defer publication, including post-ACK tails.
|
||||||
pub async fn scanner_data_usage_publication_blocked(&self) -> bool {
|
pub async fn scanner_data_usage_publication_blocked(&self) -> bool {
|
||||||
let operation_gate = self.ctx.data_movement_operation_gate();
|
let operation_gate = self.ctx.data_movement_operation_gate();
|
||||||
let _operation_guard = operation_gate.read_owned().await;
|
let _operation_guard = operation_gate.read_owned().await;
|
||||||
self.scanner_data_usage_publication_snapshot_blocked().await
|
self.scanner_data_usage_publication_snapshot_blocked().await || self.ctx.namespace_commits_pending()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scanner_data_movement_pause_status(&self) -> ScannerDataMovementPauseStatus {
|
pub async fn scanner_data_movement_pause_status(&self) -> ScannerDataMovementPauseStatus {
|
||||||
@@ -1070,7 +1071,7 @@ impl ECStore {
|
|||||||
{
|
{
|
||||||
return Err(Error::other("scanner publication lease generation is stale"));
|
return Err(Error::other("scanner publication lease generation is stale"));
|
||||||
}
|
}
|
||||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,7 +1110,7 @@ impl ECStore {
|
|||||||
{
|
{
|
||||||
return Err(Error::other("scanner publication lease generation is stale"));
|
return Err(Error::other("scanner publication lease generation is stale"));
|
||||||
}
|
}
|
||||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||||
}
|
}
|
||||||
if !self.ctx.scanner_publication_lease_is_active(token).await {
|
if !self.ctx.scanner_publication_lease_is_active(token).await {
|
||||||
@@ -1129,7 +1130,7 @@ impl ECStore {
|
|||||||
if self.ctx.data_movement_generation_exhausted() || self.ctx.data_movement_operation_epoch_exhausted() {
|
if self.ctx.data_movement_generation_exhausted() || self.ctx.data_movement_operation_epoch_exhausted() {
|
||||||
return Err(Error::other("scanner publication lease generation is exhausted"));
|
return Err(Error::other("scanner publication lease generation is exhausted"));
|
||||||
}
|
}
|
||||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||||
}
|
}
|
||||||
let Some(lease_generation) = self.ctx.scanner_publication_lease_generation(token).await else {
|
let Some(lease_generation) = self.ctx.scanner_publication_lease_generation(token).await else {
|
||||||
|
|||||||
@@ -1616,7 +1616,7 @@ where
|
|||||||
// Refresh the storage-owned movement snapshot before reading background
|
// Refresh the storage-owned movement snapshot before reading background
|
||||||
// heal state. A missing heal object yields an in-memory default; do not
|
// heal state. A missing heal object yields an in-memory default; do not
|
||||||
// let that default influence a cycle while publication is blocked.
|
// let that default influence a cycle while publication is blocked.
|
||||||
if storeapi.scanner_data_usage_publication_blocked().await {
|
if storeapi.scanner_data_movement_pause_status().await.paused {
|
||||||
mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
|
mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
|
||||||
return ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
return ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
||||||
}
|
}
|
||||||
@@ -1812,6 +1812,19 @@ where
|
|||||||
let publication_defer_reason = publication_defer_reason
|
let publication_defer_reason = publication_defer_reason
|
||||||
.or(remote_lease_defer_reason)
|
.or(remote_lease_defer_reason)
|
||||||
.or(remote_lease_fence_defer_reason);
|
.or(remote_lease_fence_defer_reason);
|
||||||
|
// A PUT tail can finish between the walk and lease acquisition without
|
||||||
|
// changing the movement epoch accepted by those leases. Re-prove the
|
||||||
|
// namespace baseline only after every peer has granted publication.
|
||||||
|
let post_lease_activity_defer_reason = if publication_defer_reason.is_none()
|
||||||
|
&& remote_publication_leases.is_some()
|
||||||
|
&& let Ok(result) = &scan_result
|
||||||
|
&& result.status == ScannerCycleStatus::Complete
|
||||||
|
{
|
||||||
|
scanner_post_lease_activity_defer_reason(result.activity_digest(), probe_scanner_activity(storeapi.as_ref(), true).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let publication_defer_reason = publication_defer_reason.or(post_lease_activity_defer_reason);
|
||||||
// Include reasons discovered while acquiring or validating remote leases.
|
// Include reasons discovered while acquiring or validating remote leases.
|
||||||
let publication_deferred = publication_defer_reason.is_some();
|
let publication_deferred = publication_defer_reason.is_some();
|
||||||
let budget_elapsed = cycle_budget.budget_elapsed() && !ctx.is_cancelled();
|
let budget_elapsed = cycle_budget.budget_elapsed() && !ctx.is_cancelled();
|
||||||
@@ -3236,6 +3249,21 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scanner_post_lease_activity_defer_reason(
|
||||||
|
expected_digest: Option<[u8; 32]>,
|
||||||
|
activity: Result<ScannerActivitySnapshot, String>,
|
||||||
|
) -> Option<ScannerCycleDeferReason> {
|
||||||
|
match activity {
|
||||||
|
Ok(snapshot)
|
||||||
|
if scanner_activity_allows_usage_publication(&snapshot)
|
||||||
|
&& expected_digest == Some(scanner_activity_snapshot_digest(&snapshot)) =>
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(_) | Err(_) => Some(ScannerCycleDeferReason::ActivityBaselineUnavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
enum ScannerCyclePreCommitOutcome {
|
enum ScannerCyclePreCommitOutcome {
|
||||||
RecoverCacheCycle(u64),
|
RecoverCacheCycle(u64),
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
use super::heal_info::{classify_background_heal_read_error, decode_background_heal_info};
|
use super::heal_info::{classify_background_heal_read_error, decode_background_heal_info};
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::EcstoreResult;
|
use crate::EcstoreResult;
|
||||||
use crate::storage_api::scan::BucketOperations as _;
|
use crate::storage_api::ecstore_hold_namespace_commit;
|
||||||
|
use crate::storage_api::scan::{BucketOperations as _, ObjectIO as _};
|
||||||
use crate::{
|
use crate::{
|
||||||
DATA_USAGE_BLOOM_RECOVERY_PATH, DATA_USAGE_CACHE_KEY_FORMAT, DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT,
|
DATA_USAGE_BLOOM_RECOVERY_PATH, DATA_USAGE_CACHE_KEY_FORMAT, DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT,
|
||||||
DataUsageCachePrepareOutcome, DataUsageCacheSource, DataUsageEntry, DataUsageScanPlanDigest, Endpoint, EndpointServerPools,
|
DataUsageCachePrepareOutcome, DataUsageCacheSource, DataUsageEntry, DataUsageScanPlanDigest, Endpoint, EndpointServerPools,
|
||||||
@@ -1163,6 +1164,92 @@ async fn run_data_scanner_cycle_publishes_activity_for_owner_lifetime() {
|
|||||||
global_metrics().set_cycle(None).await;
|
global_metrics().set_cycle(None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn coordinator_walks_during_pending_put_without_persisting_or_acknowledging_usage() {
|
||||||
|
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let bucket = format!("scanner-coordinator-pending-{}", Uuid::new_v4().simple());
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &crate::storage_api::scan::MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("fixture bucket should be created");
|
||||||
|
let mut reader = PutObjReader::from_vec(b"first".to_vec());
|
||||||
|
store.pools[0].disk_set[0]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
"object",
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fixture object should finish its rename fanout");
|
||||||
|
crate::scanner_io::record_dirty_usage_bucket(&bucket);
|
||||||
|
let dirty_before = crate::scanner_io::dirty_usage_buckets_for_tests();
|
||||||
|
let baseline = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("fixture usage baseline should be readable");
|
||||||
|
let pending = ecstore_hold_namespace_commit(store.as_ref());
|
||||||
|
let ctx = CancellationToken::new();
|
||||||
|
let budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||||
|
let mut cycle_info = CurrentCycle {
|
||||||
|
next: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut revision = DataUsageCacheRevision::Missing;
|
||||||
|
let outcome = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
run_data_scanner_cycle_with_budget(&ctx, &store, &mut cycle_info, &mut revision, 1, Arc::clone(&budget)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the coordinator must finish its namespace walk while a PUT is pending");
|
||||||
|
assert_eq!(budget.progress().0, 1, "the coordinator must reach actual object traversal");
|
||||||
|
assert_eq!(outcome, ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement));
|
||||||
|
assert_eq!(cycle_info.next, 1, "a rejected publication must not advance the cycle");
|
||||||
|
assert_eq!(revision, DataUsageCacheRevision::Missing);
|
||||||
|
assert_eq!(crate::scanner_io::dirty_usage_buckets_for_tests(), dirty_before);
|
||||||
|
assert_eq!(
|
||||||
|
read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("the prior authoritative usage must remain readable"),
|
||||||
|
baseline,
|
||||||
|
"the pending candidate must not replace the authoritative baseline"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(pending);
|
||||||
|
let outcome = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
run_data_scanner_cycle(&ctx, &store, &mut cycle_info, &mut revision, 1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the same cycle must converge after the pending PUT drains");
|
||||||
|
assert!(matches!(
|
||||||
|
outcome,
|
||||||
|
ScannerCycleOutcome::Completed | ScannerCycleOutcome::CompletedWithPendingMaintenance
|
||||||
|
));
|
||||||
|
assert_eq!(cycle_info.next, 2);
|
||||||
|
assert!(!crate::scanner_io::dirty_usage_buckets_for_tests().contains_key(&bucket));
|
||||||
|
let usage = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("the converged usage should be persisted");
|
||||||
|
let usage: DataUsageInfo = serde_json::from_slice(&usage).expect("the persisted usage should decode");
|
||||||
|
assert_eq!(usage.usage_snapshot_converged, Some(true));
|
||||||
|
assert_eq!(usage.scanner_cycle, Some(1));
|
||||||
|
assert_eq!(usage.objects_total_count, 1);
|
||||||
|
assert_eq!(usage.objects_total_size, 5);
|
||||||
|
let bucket_usage = usage
|
||||||
|
.buckets_usage
|
||||||
|
.get(&bucket)
|
||||||
|
.expect("the scanned bucket should be published");
|
||||||
|
assert_eq!(bucket_usage.objects_count, 1);
|
||||||
|
assert_eq!(bucket_usage.size, 5);
|
||||||
|
global_metrics().set_cycle(None).await;
|
||||||
|
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_finalize_partial_scan_cycle_advances_and_persists_counter() {
|
async fn test_finalize_partial_scan_cycle_advances_and_persists_counter() {
|
||||||
@@ -8111,6 +8198,66 @@ fn scanner_node_activity(epoch: &str, namespace_generation: u64, maintenance_gen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_lease_activity_proof_rejects_a_put_tail_that_finished_before_lease_acquisition() {
|
||||||
|
let before = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||||
|
let expected_digest = Some(scanner_activity_snapshot_digest(&before));
|
||||||
|
assert_eq!(scanner_post_lease_activity_defer_reason(expected_digest, Ok(before.clone())), None);
|
||||||
|
|
||||||
|
let mut after = before.clone();
|
||||||
|
after
|
||||||
|
.get_mut("node-2")
|
||||||
|
.expect("writer should be present")
|
||||||
|
.namespace_generation += 1;
|
||||||
|
assert_eq!(
|
||||||
|
before["node-2"].movement_generation, after["node-2"].movement_generation,
|
||||||
|
"the existing movement-only lease remains valid after a PUT tail drains"
|
||||||
|
);
|
||||||
|
assert!(scanner_activity_allows_usage_publication(&after));
|
||||||
|
let reason = scanner_post_lease_activity_defer_reason(expected_digest, Ok(after));
|
||||||
|
assert_eq!(reason, Some(ScannerCycleDeferReason::ActivityBaselineUnavailable));
|
||||||
|
|
||||||
|
let result = ScannerCycleResult::new(ScannerCycleStatus::Complete, None).with_remote_dirty_usage_acknowledgements(vec![
|
||||||
|
ScannerDirtyUsageAcknowledgement {
|
||||||
|
host: "node-2".to_string(),
|
||||||
|
instance_id: "epoch-a".to_string(),
|
||||||
|
generation: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let (outcome, _, acknowledgements) = finalize_scanner_cycle_result(
|
||||||
|
result,
|
||||||
|
DataUsagePersistOutcome::Deferred(reason.expect("changed namespace should defer publication")),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
acknowledgements.is_empty(),
|
||||||
|
"a rejected publication must not acknowledge the peer's dirty usage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_lease_activity_proof_requires_a_complete_matching_baseline() {
|
||||||
|
let before = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||||
|
let digest = scanner_activity_snapshot_digest(&before);
|
||||||
|
let mut blocked = before.clone();
|
||||||
|
blocked.get_mut("node-2").expect("peer should be present").publication_blocked = true;
|
||||||
|
let blocked_digest = scanner_activity_snapshot_digest(&blocked);
|
||||||
|
for (expected, observed) in [
|
||||||
|
(None, Ok(before)),
|
||||||
|
(Some(digest), Err("peer is unavailable".to_string())),
|
||||||
|
(Some(digest), Ok(BTreeMap::new())),
|
||||||
|
(Some(blocked_digest), Ok(blocked)),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
scanner_post_lease_activity_defer_reason(expected, observed),
|
||||||
|
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_activity_snapshot_digest_fences_storage_topology() {
|
fn scanner_activity_snapshot_digest_fences_storage_topology() {
|
||||||
let first = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
let first = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||||
|
|||||||
@@ -331,9 +331,12 @@ async fn scanner_cycle_activity_status<S>(
|
|||||||
where
|
where
|
||||||
S: ScannerStorage,
|
S: ScannerStorage,
|
||||||
{
|
{
|
||||||
|
// Read the pending-commit barrier before sampling its completion generation.
|
||||||
|
// A tail that drains during this await must invalidate the earlier baseline.
|
||||||
|
let publication_blocked = store.scanner_data_usage_publication_blocked().await;
|
||||||
match crate::scanner::probe_scanner_activity(store, distributed).await {
|
match crate::scanner::probe_scanner_activity(store, distributed).await {
|
||||||
Ok(after) => {
|
Ok(after) => {
|
||||||
let status = if after == *before {
|
let status = if !publication_blocked && after == *before {
|
||||||
ScannerCycleActivityStatus::Unchanged
|
ScannerCycleActivityStatus::Unchanged
|
||||||
} else {
|
} else {
|
||||||
ScannerCycleActivityStatus::Changed
|
ScannerCycleActivityStatus::Changed
|
||||||
@@ -635,6 +638,7 @@ fn scanner_activity_preflight(
|
|||||||
pub(crate) struct ScannerCycleResult {
|
pub(crate) struct ScannerCycleResult {
|
||||||
pub(crate) status: ScannerCycleStatus,
|
pub(crate) status: ScannerCycleStatus,
|
||||||
publication_epoch: Option<u64>,
|
publication_epoch: Option<u64>,
|
||||||
|
activity_digest: Option<[u8; 32]>,
|
||||||
observational_snapshot_published: bool,
|
observational_snapshot_published: bool,
|
||||||
dirty_usage_clear: Option<DirtyUsageBuckets>,
|
dirty_usage_clear: Option<DirtyUsageBuckets>,
|
||||||
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
||||||
@@ -649,6 +653,7 @@ impl ScannerCycleResult {
|
|||||||
Self {
|
Self {
|
||||||
status,
|
status,
|
||||||
publication_epoch: None,
|
publication_epoch: None,
|
||||||
|
activity_digest: None,
|
||||||
observational_snapshot_published: false,
|
observational_snapshot_published: false,
|
||||||
dirty_usage_clear,
|
dirty_usage_clear,
|
||||||
remote_dirty_usage_acknowledgements: Vec::new(),
|
remote_dirty_usage_acknowledgements: Vec::new(),
|
||||||
@@ -668,6 +673,15 @@ impl ScannerCycleResult {
|
|||||||
self.publication_epoch
|
self.publication_epoch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn with_activity_digest(mut self, activity_digest: [u8; 32]) -> Self {
|
||||||
|
self.activity_digest = Some(activity_digest);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn activity_digest(&self) -> Option<[u8; 32]> {
|
||||||
|
self.activity_digest
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn with_observational_snapshot_published(mut self, published: bool) -> Self {
|
pub(crate) fn with_observational_snapshot_published(mut self, published: bool) -> Self {
|
||||||
self.observational_snapshot_published = published;
|
self.observational_snapshot_published = published;
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ where
|
|||||||
// canceled decommission remains suspended after its worker exits, so
|
// canceled decommission remains suspended after its worker exits, so
|
||||||
// starting a scan in that state could build a snapshot that cannot be
|
// starting a scan in that state could build a snapshot that cannot be
|
||||||
// routed to the authoritative metadata object.
|
// routed to the authoritative metadata object.
|
||||||
if store.scanner_data_usage_publication_blocked().await {
|
if store.scanner_data_movement_pause_status().await.paused {
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::scanner::io",
|
target: "rustfs::scanner::io",
|
||||||
event = EVENT_SCANNER_SET_STATE,
|
event = EVENT_SCANNER_SET_STATE,
|
||||||
@@ -185,8 +185,8 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bucket_plan_complete &= buckets_by_source.keys().copied().collect::<HashSet<_>>() == *expected_sources;
|
bucket_plan_complete &= buckets_by_source.keys().copied().collect::<HashSet<_>>() == *expected_sources;
|
||||||
let scan_plan_digest =
|
let activity_digest = crate::scanner::scanner_activity_snapshot_digest(&activity_before);
|
||||||
scanner_bucket_plan_digest(&all_buckets, crate::scanner::scanner_activity_snapshot_digest(&activity_before));
|
let scan_plan_digest = scanner_bucket_plan_digest(&all_buckets, activity_digest);
|
||||||
let dirty_usage_snapshot = Arc::new(snapshot_dirty_usage_buckets(&all_buckets, dirty_generation_before_bucket_list));
|
let dirty_usage_snapshot = Arc::new(snapshot_dirty_usage_buckets(&all_buckets, dirty_generation_before_bucket_list));
|
||||||
let cache_cycle_floor = Arc::new(AtomicU64::new(want_cycle));
|
let cache_cycle_floor = Arc::new(AtomicU64::new(want_cycle));
|
||||||
let tier_registry = runtime_tier_registry_for_cycle(want_cycle, leader_epoch).await;
|
let tier_registry = runtime_tier_registry_for_cycle(want_cycle, leader_epoch).await;
|
||||||
@@ -233,6 +233,7 @@ where
|
|||||||
};
|
};
|
||||||
return Ok(ScannerCycleResult::new(status, dirty_usage_clear)
|
return Ok(ScannerCycleResult::new(status, dirty_usage_clear)
|
||||||
.with_publication_epoch(publication_epoch)
|
.with_publication_epoch(publication_epoch)
|
||||||
|
.with_activity_digest(activity_digest)
|
||||||
.with_observational_snapshot_published(observational_snapshot_published)
|
.with_observational_snapshot_published(observational_snapshot_published)
|
||||||
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
||||||
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements));
|
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements));
|
||||||
@@ -505,6 +506,7 @@ where
|
|||||||
};
|
};
|
||||||
Ok(ScannerCycleResult::new(cycle_status, dirty_usage_clear)
|
Ok(ScannerCycleResult::new(cycle_status, dirty_usage_clear)
|
||||||
.with_publication_epoch(publication_epoch)
|
.with_publication_epoch(publication_epoch)
|
||||||
|
.with_activity_digest(activity_digest)
|
||||||
.with_observational_snapshot_published(observational_snapshot_published)
|
.with_observational_snapshot_published(observational_snapshot_published)
|
||||||
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
||||||
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
|
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use super::io_disk::tier_stats_template;
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::scanner_budget::ScannerCycleBudgetConfig;
|
use crate::scanner_budget::ScannerCycleBudgetConfig;
|
||||||
use crate::scanner_folder::ScannerItem;
|
use crate::scanner_folder::ScannerItem;
|
||||||
|
use crate::storage_api::ecstore_hold_namespace_commit;
|
||||||
use crate::storage_api::owner::{
|
use crate::storage_api::owner::{
|
||||||
EcstorePoolDecommissionInfo, EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats,
|
EcstorePoolDecommissionInfo, EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats,
|
||||||
};
|
};
|
||||||
@@ -342,6 +343,16 @@ async fn multi_pool_scanner_cycle_publishes_combined_usage() {
|
|||||||
.put_object(&bucket, object, &mut reader, &ScannerObjectOptions::default())
|
.put_object(&bucket, object, &mut reader, &ScannerObjectOptions::default())
|
||||||
.await
|
.await
|
||||||
.expect("object should be written to its selected pool");
|
.expect("object should be written to its selected pool");
|
||||||
|
|
||||||
|
// Quorum ACK can precede tail publication on the disk chosen to scan.
|
||||||
|
let lock = store.pools[pool_index].disk_set[0]
|
||||||
|
.new_ns_lock(&bucket, object)
|
||||||
|
.await
|
||||||
|
.expect("fixture namespace lock should be created");
|
||||||
|
let _settled = lock
|
||||||
|
.get_write_lock(Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("fixture rename tail should finish before the usage scan");
|
||||||
}
|
}
|
||||||
|
|
||||||
let ctx = CancellationToken::new();
|
let ctx = CancellationToken::new();
|
||||||
@@ -361,7 +372,7 @@ async fn multi_pool_scanner_cycle_publishes_combined_usage() {
|
|||||||
.buckets_usage
|
.buckets_usage
|
||||||
.get(&bucket)
|
.get(&bucket)
|
||||||
.expect("combined bucket usage should be present");
|
.expect("combined bucket usage should be present");
|
||||||
assert_eq!(bucket_usage.objects_count, 2);
|
assert_eq!(bucket_usage.objects_count, 2, "{usage:?}");
|
||||||
assert_eq!(bucket_usage.size, 11);
|
assert_eq!(bucket_usage.size, 11);
|
||||||
assert_eq!(usage.objects_total_count, 2);
|
assert_eq!(usage.objects_total_count, 2);
|
||||||
assert_eq!(usage.objects_total_size, 11);
|
assert_eq!(usage.objects_total_size, 11);
|
||||||
@@ -371,6 +382,85 @@ async fn multi_pool_scanner_cycle_publishes_combined_usage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn pending_put_commit_keeps_scanner_walk_live_without_authoritative_usage() {
|
||||||
|
let (_temp_dir, store) = setup_two_pool_scanner_store().await;
|
||||||
|
let bucket = format!("scanner-pending-put-{}", Uuid::new_v4().simple());
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("bucket should be created across both pools");
|
||||||
|
for (pool_index, (object, body)) in [("pool-a", b"first".as_slice()), ("pool-b", b"second".as_slice())]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let mut reader = ScannerPutObjReader::from_vec(body.to_vec());
|
||||||
|
store.pools[pool_index].disk_set[0]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
&mut reader,
|
||||||
|
&ScannerObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fixture objects must finish their rename fanouts before scanning");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pending = Some(ecstore_hold_namespace_commit(store.as_ref()));
|
||||||
|
for (cycle, converged) in [(1, false), (2, true)] {
|
||||||
|
if converged {
|
||||||
|
drop(pending.take());
|
||||||
|
}
|
||||||
|
assert_eq!(store.scanner_data_usage_publication_blocked().await, !converged);
|
||||||
|
assert!(!store.scanner_data_movement_pause_status().await.paused);
|
||||||
|
let ctx = CancellationToken::new();
|
||||||
|
let budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||||
|
let (updates, mut receiver) = mpsc::channel(1);
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
ScannerIOCycle::nsscanner_with_status(
|
||||||
|
store.as_ref(),
|
||||||
|
ctx,
|
||||||
|
Arc::clone(&budget),
|
||||||
|
updates,
|
||||||
|
cycle,
|
||||||
|
1,
|
||||||
|
HealScanMode::Normal,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("namespace scanning must finish while a PUT commit is pending")
|
||||||
|
.expect("namespace scanning must remain available during a pending PUT commit");
|
||||||
|
if !converged {
|
||||||
|
assert_eq!(budget.progress().0, 2, "the pending commit must not suppress actual object traversal");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
if converged {
|
||||||
|
ScannerCycleStatus::Complete
|
||||||
|
} else {
|
||||||
|
ScannerCycleStatus::Superseded
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let usage = receiver
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("the completed walk should produce a usage candidate");
|
||||||
|
assert_eq!(usage.usage_snapshot_converged, Some(converged));
|
||||||
|
assert_eq!(usage.scanner_cycle, Some(cycle));
|
||||||
|
assert_eq!(usage.objects_total_count, 2);
|
||||||
|
assert_eq!(usage.objects_total_size, 11);
|
||||||
|
let bucket_usage = usage.buckets_usage.get(&bucket).expect("the walked bucket must be present");
|
||||||
|
assert_eq!(bucket_usage.objects_count, 2);
|
||||||
|
assert_eq!(bucket_usage.size, 11);
|
||||||
|
assert!(receiver.recv().await.is_none(), "each walk must emit exactly one terminal candidate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn multi_pool_scanner_cycle_zero_fills_bucket_absent_from_first_pool() {
|
async fn multi_pool_scanner_cycle_zero_fills_bucket_absent_from_first_pool() {
|
||||||
@@ -386,6 +476,16 @@ async fn multi_pool_scanner_cycle_zero_fills_bucket_absent_from_first_pool() {
|
|||||||
.put_object(&bucket, "pool-b", &mut reader, &ScannerObjectOptions::default())
|
.put_object(&bucket, "pool-b", &mut reader, &ScannerObjectOptions::default())
|
||||||
.await
|
.await
|
||||||
.expect("object should be written only to the second pool");
|
.expect("object should be written only to the second pool");
|
||||||
|
{
|
||||||
|
let lock = store.pools[1].disk_set[0]
|
||||||
|
.new_ns_lock(&bucket, "pool-b")
|
||||||
|
.await
|
||||||
|
.expect("fixture namespace lock should be created");
|
||||||
|
let _settled = lock
|
||||||
|
.get_write_lock(Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("fixture rename tail should finish before the usage scan");
|
||||||
|
}
|
||||||
store.pools[0]
|
store.pools[0]
|
||||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ pub(crate) use rustfs_ecstore::api::runtime::{
|
|||||||
};
|
};
|
||||||
pub(crate) use rustfs_ecstore::api::set_disk::SetDisks as EcstoreSetDisks;
|
pub(crate) use rustfs_ecstore::api::set_disk::SetDisks as EcstoreSetDisks;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
pub(crate) use rustfs_ecstore::api::set_disk::test_util::hold_namespace_commit as ecstore_hold_namespace_commit;
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::storage::SCANNER_PUBLICATION_LEASE_TTL_MS as ECSTORE_SCANNER_PUBLICATION_LEASE_TTL_MS;
|
pub(crate) use rustfs_ecstore::api::storage::SCANNER_PUBLICATION_LEASE_TTL_MS as ECSTORE_SCANNER_PUBLICATION_LEASE_TTL_MS;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::storage::init_local_disks_with_instance_ctx as ecstore_init_local_disks_with_instance_ctx;
|
pub(crate) use rustfs_ecstore::api::storage::init_local_disks_with_instance_ctx as ecstore_init_local_disks_with_instance_ctx;
|
||||||
|
|||||||
@@ -23,6 +23,30 @@ therefore has three identities:
|
|||||||
If any identity changes before commit, the result is a candidate for retry or
|
If any identity changes before commit, the result is a candidate for retry or
|
||||||
observation, not an authoritative baseline.
|
observation, not an authoritative baseline.
|
||||||
|
|
||||||
|
Ordinary PUT rename fanouts also track instance-scoped in-flight work. A quorum
|
||||||
|
ACK does not release it: the actual disk tasks retain ownership until their
|
||||||
|
rename work ends, including when the request caller is cancelled. Scan admission
|
||||||
|
remains movement-only so sustained PUTs do not stop namespace walks and
|
||||||
|
scanner-driven lifecycle discovery. The post-walk local publication check and
|
||||||
|
remote publication leases reject pending fanouts. Begin/end namespace generations
|
||||||
|
invalidate scans and cached plans across the fanout; after acquiring remote
|
||||||
|
leases, the coordinator rechecks the full activity digest before publishing an
|
||||||
|
authoritative aggregate. This catches a tail that finishes between the scan's
|
||||||
|
last probe and lease acquisition.
|
||||||
|
|
||||||
|
This adds no namespace or movement lock. An already-verified older snapshot may
|
||||||
|
still precede a newly started write. Sustained or stalled PUT tails can delay
|
||||||
|
authoritative usage publication, which resumes through the existing retry
|
||||||
|
schedule rather than a new immediate-wakeup protocol. Intermediate per-set and
|
||||||
|
prefix cache readers retain their existing approximate-cache semantics. A
|
||||||
|
prolonged pending tail with no generation changes can also delay cycle advancement
|
||||||
|
and fresh rescans of already-current caches; this is not a guarantee of lifecycle
|
||||||
|
progress under indefinitely stalled storage I/O.
|
||||||
|
|
||||||
|
This PUT-tail protection requires every writer node to be upgraded. It does not
|
||||||
|
prove that a failed tail replica has healed, and it does not extend the same
|
||||||
|
in-flight tracking to multipart or other namespace mutation paths.
|
||||||
|
|
||||||
## Fences
|
## Fences
|
||||||
|
|
||||||
The protocol uses separate fences because they exclude different stale inputs.
|
The protocol uses separate fences because they exclude different stale inputs.
|
||||||
|
|||||||
@@ -79,9 +79,11 @@ Setting `"enabled": false` in the config has the same read-path effect as deleti
|
|||||||
|
|
||||||
The status endpoint reports **the node that answered the request**. Counters, queue depth and breaker state are per-node runtime state, so in a distributed deployment query every node; the saved configuration and `updated_at` are cluster-wide.
|
The status endpoint reports **the node that answered the request**. Counters, queue depth and breaker state are per-node runtime state, so in a distributed deployment query every node; the saved configuration and `updated_at` are cluster-wide.
|
||||||
|
|
||||||
### Backfill (ships with ODM-12)
|
### Backfill
|
||||||
|
|
||||||
Read-through only migrates what clients touch. The background backfill job walks the source listing and pulls the remainder, with a persisted checkpoint (`.rustfs.sys/buckets/<bucket>/on-demand-migration-backfill.json`), a single-owner lease, resume after restart, and `POST .../{bucket}/backfill?op=start|cancel` plus `GET .../{bucket}/backfill` admin routes. That slice (rustfs/backlog#2159) is not part of the build this page was written against: the shape above is the agreed design, and the exact request/response bodies must be re-checked against `docs/architecture/admin-route-action-snapshot.md` once it lands.
|
Backfill waits for the result of every pull, including a pull already queued by an online request. A failed or cancelled shared pull is counted as a failure, never as successful migration. The persisted continuation cursor stays at the first failed page; a takeover replays from there and skips objects already present locally. `completed_with_failures` is not a cutover-ready state.
|
||||||
|
|
||||||
|
Read-through only migrates what clients touch. The background backfill job walks the source listing and pulls the remainder, with a persisted checkpoint (`.rustfs.sys/buckets/<bucket>/on-demand-migration-backfill.json`), a single-owner lease, resume after restart, and `POST .../{bucket}/backfill?op=start|cancel` plus `GET .../{bucket}/backfill` admin routes. See `docs/architecture/admin-route-action-snapshot.md` for the route contract.
|
||||||
|
|
||||||
## Configuration reference
|
## Configuration reference
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ The persisted blob is `on-demand-migration.json` in the bucket's metadata. Unkno
|
|||||||
| `policy.source_timeout.idle_ms` | integer | `30000` | `100..=600000`; enforced per body chunk on both the background pump and the inline tee |
|
| `policy.source_timeout.idle_ms` | integer | `30000` | `100..=600000`; enforced per body chunk on both the background pump and the inline tee |
|
||||||
| `policy.bandwidth_limit_bytes_per_sec` | integer \| null | `null` | When set, at least `65536` |
|
| `policy.bandwidth_limit_bytes_per_sec` | integer \| null | `null` | When set, at least `65536` |
|
||||||
|
|
||||||
Values that are **not** configurable: the breaker opens after 5 consecutive counted failures inside a 30 s window, stays open for 30 s and then admits one probe (`breaker.rs`); the negative cache holds at most 100 000 keys per bucket with LRU eviction (`negative_cache.rs`); a background pull retries a retryable source failure at most 3 times with 1 s / 4 s / 16 s base delays plus up to 25 % jitter (`pull.rs`). The SDK's own retry policy is disabled on the source client, so one logical source call is exactly one wire request and the retry budget above is the only one.
|
Values that are **not** configurable: the breaker opens after 5 consecutive counted failures inside a 30 s window, stays open for 30 s and then admits one probe (`breaker.rs`); the negative cache holds at most 100 000 keys per bucket with LRU eviction (`negative_cache.rs`); a background pull retries a retryable source failure at most 3 times with 1 s / 4 s / 16 s base delays plus up to 25 % jitter (`pull.rs`). The SDK's own retry policy is disabled on the source client. Each SDK operation makes one wire request; an ambiguous HEAD 404 additionally probes the bucket, within the same configured first-byte budget.
|
||||||
|
|
||||||
Validation also rejects two shapes outright: a source whose endpoint and bucket name **this** bucket on this deployment (`SelfReference`), and a source that matches one of the bucket's own replication targets (`ReplicationLoop`) — that pairing would amplify a write-back into a loop.
|
Validation also rejects two shapes outright: a source whose endpoint and bucket name **this** bucket on this deployment (`SelfReference`), and a source that matches one of the bucket's own replication targets (`ReplicationLoop`) — that pairing would amplify a write-back into a loop.
|
||||||
|
|
||||||
@@ -150,6 +152,14 @@ No write, delete, ACL or versioning permission is required or used. Scope the po
|
|||||||
|
|
||||||
Behaviour a client can observe. The "Test" column names the case that pins it: `*_test.rs` files live under `crates/e2e_test/src/on_demand_migration/`, and the unit tests live next to the code in `rustfs/src/app/object/get.rs`, `head.rs` and `shared.rs`.
|
Behaviour a client can observe. The "Test" column names the case that pins it: `*_test.rs` files live under `crates/e2e_test/src/on_demand_migration/`, and the unit tests live next to the code in `rustfs/src/app/object/get.rs`, `head.rs` and `shared.rs`.
|
||||||
|
|
||||||
|
ODM merged continuation tokens use a NUL-prefixed JSON envelope inside the existing base64 encoding. NUL is not valid in a local object key, so a legitimate JSON-shaped key can never be mistaken for a merged cursor. Upgrade every node before using list-through, and restart any in-progress ODM listing issued by an older build: its unframed JSON tokens cannot be distinguished from legitimate local keys. Ordinary local listing tokens remain unchanged. Tokens issued by this build can still resume the local side after list-through is disabled.
|
||||||
|
|
||||||
|
Source `HEAD` responses with status 404 require a successful bucket probe before being negative-cached. The source credential therefore needs permission for `HeadBucket` (S3 `ListBucket`); a prefix-restricted ListBucket policy can deny that probe, in which case the response is a source failure rather than a cached miss. A missing/inaccessible source bucket, a missing source version, or an ambiguous GET 404 is not proof that the requested key is absent. Conditional GET validators are checked against the actual source GET metadata as well as the advisory HEAD; a missing required validator fails with 424. Source LIST entries without a key or a non-negative size fail the page rather than fabricating an empty object.
|
||||||
|
|
||||||
|
Write-back currently requires namespace locking enabled and exactly one pool with one erasure set. Other topologies fail write-back explicitly as `unsupported`: source reads remain available, but backfill cannot complete successfully or certify cutover. This restriction avoids relying on a set-local condition across distinct pool or lock domains; it does not restrict ordinary S3 writes. Full cross-pool migration requires a globally fenced commit protocol.
|
||||||
|
|
||||||
|
On the supported topology, write-back uses a create-only check under the local storage commit lock for both single-part PUT and multipart completion. A client write that commits while ODM is reading the source is preserved. With `respect_local_delete_marker=true`, a concurrent versioned deletion is preserved too. An explicit `respect_local_delete_marker=false` still permits revival; an unversioned deletion has no tombstone and therefore cannot be distinguished from a key that has never existed locally.
|
||||||
|
|
||||||
| Situation | Behaviour | Test |
|
| Situation | Behaviour | Test |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET miss, object at or below `inline_max_bytes` | One source GET, teed: the client streams while the same bytes are written locally. Later reads are local and carry no source marker | `get_basic_test.rs::get_miss_pulls_inline_and_serves_locally_afterwards`, `get.rs::odm_get_inline_streams_to_client_and_commits_the_same_bytes` |
|
| GET miss, object at or below `inline_max_bytes` | One source GET, teed: the client streams while the same bytes are written locally. Later reads are local and carry no source marker | `get_basic_test.rs::get_miss_pulls_inline_and_serves_locally_afterwards`, `get.rs::odm_get_inline_streams_to_client_and_commits_the_same_bytes` |
|
||||||
@@ -219,7 +229,7 @@ Five provenance keys are written on every pulled object under both internal pref
|
|||||||
| Concurrency limit | Local write amplification | `max_concurrent_pulls` permits shared by inline and background pulls |
|
| Concurrency limit | Local write amplification | `max_concurrent_pulls` permits shared by inline and background pulls |
|
||||||
| Bounded queue | Unbounded memory on a burst | `pull_queue_capacity` waiting jobs; overflow is counted as `queue_full` and never fails a client response |
|
| Bounded queue | Unbounded memory on a burst | `pull_queue_capacity` waiting jobs; overflow is counted as `queue_full` and never fails a client response |
|
||||||
| Bandwidth limit | Source and network saturation | `bandwidth_limit_bytes_per_sec` (minimum 64 KiB/s) on the source client |
|
| Bandwidth limit | Source and network saturation | `bandwidth_limit_bytes_per_sec` (minimum 64 KiB/s) on the source client |
|
||||||
| Retry budget | Transient source blips | Background pulls retry a retryable failure up to 3 times (1 s / 4 s / 16 s plus jitter). Inline pulls never retry: the bytes are already on their way to the client. The SDK retry policy on the source client is disabled (`RemoteS3RetryPolicy::Disabled`), so this is the only retry budget and one logical source call is exactly one wire request — replication targets keep the SDK's three attempts, declared on their own spec |
|
| Retry budget | Transient source blips | Background pulls retry a retryable failure up to 3 times (1 s / 4 s / 16 s plus jitter). Inline pulls never retry: the bytes are already on their way to the client. The SDK retry policy is disabled (`RemoteS3RetryPolicy::Disabled`); HEAD 404 also requires one bucket probe. Replication targets keep their separately declared three SDK attempts |
|
||||||
| Idle timeout | A source that answers and then goes quiet mid-body | `source_timeout.idle_ms` per body chunk on both paths. The budget measures the source read, upstream of the inline tee, so a slow client is never mistaken for an idle source; when it fires the client stream ends in an error and the write-back is discarded |
|
| Idle timeout | A source that answers and then goes quiet mid-body | `source_timeout.idle_ms` per body chunk on both paths. The budget measures the source read, upstream of the inline tee, so a slow client is never mistaken for an idle source; when it fires the client stream ends in an error and the write-back is discarded |
|
||||||
| Anti-loop marker | Migration chains between RustFS/MinIO deployments | Every source request carries `x-rustfs-source-proxy-request` and `x-minio-source-proxy-request`; a request carrying it is always answered locally |
|
| Anti-loop marker | Migration chains between RustFS/MinIO deployments | Every source request carries `x-rustfs-source-proxy-request` and `x-minio-source-proxy-request`; a request carrying it is always answered locally |
|
||||||
| Outbound endpoint policy | SSRF | See [outbound-connection-policy.md](outbound-connection-policy.md) |
|
| Outbound endpoint policy | SSRF | See [outbound-connection-policy.md](outbound-connection-policy.md) |
|
||||||
|
|||||||
@@ -496,6 +496,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_plain_local_token_is_passed_through_and_a_tampered_one_is_rejected() {
|
fn a_plain_local_token_is_passed_through_and_a_tampered_one_is_rejected() {
|
||||||
|
let json_key = r#"{"t":"odm-list","v":1,"local_done":true}"#;
|
||||||
|
assert!(decode_list_cursor(Some(json_key)).expect("valid local key").is_none());
|
||||||
|
assert!(matches!(local_cursor(Some(json_key), None), LocalListCursor::Token(Some(local)) if local == json_key));
|
||||||
assert!(
|
assert!(
|
||||||
decode_list_cursor(Some("photos/a.jpg"))
|
decode_list_cursor(Some("photos/a.jpg"))
|
||||||
.expect("plain markers decode")
|
.expect("plain markers decode")
|
||||||
|
|||||||
@@ -4615,6 +4615,7 @@ fn odm_inline_client_body(primary: TeePrimary) -> StreamingBlob {
|
|||||||
async fn odm_get_passthrough<S: OdmGetSource>(
|
async fn odm_get_passthrough<S: OdmGetSource>(
|
||||||
state: &Arc<BucketOdmState>,
|
state: &Arc<BucketOdmState>,
|
||||||
source: &S,
|
source: &S,
|
||||||
|
headers: &HeaderMap,
|
||||||
key: &str,
|
key: &str,
|
||||||
range: Option<&HTTPRangeSpec>,
|
range: Option<&HTTPRangeSpec>,
|
||||||
backfill: Option<PullReason>,
|
backfill: Option<PullReason>,
|
||||||
@@ -4623,6 +4624,9 @@ async fn odm_get_passthrough<S: OdmGetSource>(
|
|||||||
Ok(get) => get,
|
Ok(get) => get,
|
||||||
Err(err) => return OdmGetReply::Error(odm_get_source_failure(state, &err)),
|
Err(err) => return OdmGetReply::Error(odm_get_source_failure(state, &err)),
|
||||||
};
|
};
|
||||||
|
if let Err(err) = odm_check_source_preconditions(headers, &get.head) {
|
||||||
|
return OdmGetReply::Error(err);
|
||||||
|
}
|
||||||
let content_length = match odm_content_length(get.head.size) {
|
let content_length = match odm_content_length(get.head.size) {
|
||||||
Ok(length) => length,
|
Ok(length) => length,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -4648,6 +4652,7 @@ async fn odm_get_passthrough<S: OdmGetSource>(
|
|||||||
async fn odm_get_inline<S: OdmGetSource>(
|
async fn odm_get_inline<S: OdmGetSource>(
|
||||||
state: &Arc<BucketOdmState>,
|
state: &Arc<BucketOdmState>,
|
||||||
source: &S,
|
source: &S,
|
||||||
|
headers: &HeaderMap,
|
||||||
key: &str,
|
key: &str,
|
||||||
leader: PullLeader,
|
leader: PullLeader,
|
||||||
request_context: Option<request_context::RequestContext>,
|
request_context: Option<request_context::RequestContext>,
|
||||||
@@ -4676,6 +4681,12 @@ async fn odm_get_inline<S: OdmGetSource>(
|
|||||||
body,
|
body,
|
||||||
content_range,
|
content_range,
|
||||||
} = get;
|
} = get;
|
||||||
|
// HEAD and GET can observe different source versions. Validate the
|
||||||
|
// representation whose body will actually be returned and persisted.
|
||||||
|
if let Err(err) = odm_check_source_preconditions(headers, &head) {
|
||||||
|
leader.complete(Err(PullError::canceled("source GET did not satisfy request preconditions")));
|
||||||
|
return OdmGetReply::Error(err);
|
||||||
|
}
|
||||||
// The object outgrew the inline budget between HEAD and GET: followers
|
// The object outgrew the inline budget between HEAD and GET: followers
|
||||||
// stream through on their own and the background pull stores it.
|
// stream through on their own and the background pull stores it.
|
||||||
if head.size > policy.inline_max_bytes {
|
if head.size > policy.inline_max_bytes {
|
||||||
@@ -4758,19 +4769,19 @@ pub(super) async fn odm_get_from_source<S: OdmGetSource>(
|
|||||||
let policy = &state.config().policy;
|
let policy = &state.config().policy;
|
||||||
if let Some(range) = range {
|
if let Some(range) = range {
|
||||||
let backfill = (policy.range_get == RangeGetPolicy::ServeAndBackfill).then_some(PullReason::RangeGet);
|
let backfill = (policy.range_get == RangeGetPolicy::ServeAndBackfill).then_some(PullReason::RangeGet);
|
||||||
return odm_get_passthrough(state, source, key, Some(range), backfill).await;
|
return odm_get_passthrough(state, source, headers, key, Some(range), backfill).await;
|
||||||
}
|
}
|
||||||
if head.size > policy.inline_max_bytes {
|
if head.size > policy.inline_max_bytes {
|
||||||
return odm_get_passthrough(state, source, key, None, Some(PullReason::LargeObject)).await;
|
return odm_get_passthrough(state, source, headers, key, None, Some(PullReason::LargeObject)).await;
|
||||||
}
|
}
|
||||||
let slot = match state.acquire_pull_slot(key).await {
|
let slot = match state.acquire_pull_slot(key).await {
|
||||||
Ok(slot) => slot,
|
Ok(slot) => slot,
|
||||||
// The bucket state was torn down under this request: serve it
|
// The bucket state was torn down under this request: serve it
|
||||||
// without queueing anything on the old state.
|
// without queueing anything on the old state.
|
||||||
Err(_) => return odm_get_passthrough(state, source, key, None, None).await,
|
Err(_) => return odm_get_passthrough(state, source, headers, key, None, None).await,
|
||||||
};
|
};
|
||||||
match slot {
|
match slot {
|
||||||
PullSlot::Leader(leader) => odm_get_inline(state, source, key, leader, request_context).await,
|
PullSlot::Leader(leader) => odm_get_inline(state, source, headers, key, leader, request_context).await,
|
||||||
PullSlot::Follower(follower) => {
|
PullSlot::Follower(follower) => {
|
||||||
let first_byte = Duration::from_millis(policy.source_timeout.first_byte_ms);
|
let first_byte = Duration::from_millis(policy.source_timeout.first_byte_ms);
|
||||||
match tokio::time::timeout(first_byte, follower.wait()).await {
|
match tokio::time::timeout(first_byte, follower.wait()).await {
|
||||||
@@ -4778,7 +4789,7 @@ pub(super) async fn odm_get_from_source<S: OdmGetSource>(
|
|||||||
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
||||||
OdmGetReply::RetryLocal
|
OdmGetReply::RetryLocal
|
||||||
}
|
}
|
||||||
Ok(Err(_)) | Err(_) => odm_get_passthrough(state, source, key, None, None).await,
|
Ok(Err(_)) | Err(_) => odm_get_passthrough(state, source, headers, key, None, None).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5296,6 +5307,73 @@ mod on_demand_migration_tests {
|
|||||||
assert!(rt.write_back.puts().is_empty());
|
assert!(rt.write_back.puts().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn odm_get_rechecks_conditions_against_the_get_representation() {
|
||||||
|
for inline_max_bytes in [0, 1024] {
|
||||||
|
for range in [
|
||||||
|
None,
|
||||||
|
Some(HTTPRangeSpec {
|
||||||
|
is_suffix_length: false,
|
||||||
|
start: 0,
|
||||||
|
end: 2,
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
let rt = runtime(
|
||||||
|
"changed-source",
|
||||||
|
PolicyConfig {
|
||||||
|
inline_max_bytes,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let state = rt.state("changed-source");
|
||||||
|
let before = source_head(b"before");
|
||||||
|
let after = source_head(b"after!");
|
||||||
|
let source = ScriptedSource::new(vec![Ok(before.clone())], vec![Ok((after, b"after!".to_vec(), None))]);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
http::header::IF_MATCH,
|
||||||
|
HeaderValue::from_str(&format!("\"{}\"", before.etag.expect("etag"))).expect("header"),
|
||||||
|
);
|
||||||
|
let error = failed(odm_get_from_source(&state, &source, &headers, KEY, range.as_ref(), None).await);
|
||||||
|
assert_eq!(error.code(), &S3ErrorCode::PreconditionFailed);
|
||||||
|
assert_eq!(source.get_calls(), 1);
|
||||||
|
assert_eq!(state.inflight_keys(), 0);
|
||||||
|
assert!(rt.write_back.puts().is_empty(), "a failed condition must not start write-back");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn odm_get_missing_validators_cannot_bypass_a_condition() {
|
||||||
|
for inline_max_bytes in [0, 1024] {
|
||||||
|
let rt = runtime(
|
||||||
|
"missing-validator",
|
||||||
|
PolicyConfig {
|
||||||
|
inline_max_bytes,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let state = rt.state("missing-validator");
|
||||||
|
let before = source_head(b"before");
|
||||||
|
let after = SourceHead {
|
||||||
|
size: 6,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let source = ScriptedSource::new(vec![Ok(before.clone())], vec![Ok((after, b"after!".to_vec(), None))]);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
http::header::IF_MATCH,
|
||||||
|
HeaderValue::from_str(&format!("\"{}\"", before.etag.expect("etag"))).expect("header"),
|
||||||
|
);
|
||||||
|
let error = failed(odm_get_from_source(&state, &source, &headers, KEY, None, None).await);
|
||||||
|
assert_eq!(error.status_code(), Some(StatusCode::FAILED_DEPENDENCY));
|
||||||
|
assert_eq!(error.message(), Some("missing_source_validator"));
|
||||||
|
assert!(rt.write_back.puts().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn odm_get_source_not_found_is_404_and_negative_cached() {
|
async fn odm_get_source_not_found_is_404_and_negative_cached() {
|
||||||
let rt = runtime("n", PolicyConfig::default()).await;
|
let rt = runtime("n", PolicyConfig::default()).await;
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ pub(crate) struct InternalPutContext {
|
|||||||
pub(crate) expected_md5_hex: Option<String>,
|
pub(crate) expected_md5_hex: Option<String>,
|
||||||
/// ETag to store instead of the computed one.
|
/// ETag to store instead of the computed one.
|
||||||
pub(crate) preserve_etag: Option<String>,
|
pub(crate) preserve_etag: Option<String>,
|
||||||
|
/// Reject an existing current object under the storage commit lock.
|
||||||
|
pub(crate) if_absent: bool,
|
||||||
|
pub(crate) preserve_delete_marker: bool,
|
||||||
pub(crate) content_headers: HashMap<String, String>,
|
pub(crate) content_headers: HashMap<String, String>,
|
||||||
pub(crate) user_metadata: HashMap<String, String>,
|
pub(crate) user_metadata: HashMap<String, String>,
|
||||||
pub(crate) tags: Option<String>,
|
pub(crate) tags: Option<String>,
|
||||||
@@ -240,6 +243,8 @@ impl DefaultObjectUsecase {
|
|||||||
size,
|
size,
|
||||||
expected_md5_hex,
|
expected_md5_hex,
|
||||||
preserve_etag,
|
preserve_etag,
|
||||||
|
if_absent,
|
||||||
|
preserve_delete_marker,
|
||||||
content_headers,
|
content_headers,
|
||||||
user_metadata,
|
user_metadata,
|
||||||
tags,
|
tags,
|
||||||
@@ -252,7 +257,10 @@ impl DefaultObjectUsecase {
|
|||||||
};
|
};
|
||||||
let size = i64::try_from(size).map_err(|_| ApiError::invalid_request("internal put size exceeds the supported range"))?;
|
let size = i64::try_from(size).map_err(|_| ApiError::invalid_request("internal put size exceeds the supported range"))?;
|
||||||
|
|
||||||
let headers = internal_put_headers(&content_headers)?;
|
let mut headers = internal_put_headers(&content_headers)?;
|
||||||
|
if if_absent {
|
||||||
|
headers.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("*"));
|
||||||
|
}
|
||||||
validate_internal_write_target(&key, &bucket, &headers).await?;
|
validate_internal_write_target(&key, &bucket, &headers).await?;
|
||||||
remove_source_replication_bookkeeping(&mut internal_metadata);
|
remove_source_replication_bookkeeping(&mut internal_metadata);
|
||||||
|
|
||||||
@@ -287,6 +295,7 @@ impl DefaultObjectUsecase {
|
|||||||
origin: PutObjectOrigin::Internal {
|
origin: PutObjectOrigin::Internal {
|
||||||
principal_id,
|
principal_id,
|
||||||
emit_events,
|
emit_events,
|
||||||
|
preserve_delete_marker,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let committed = self
|
let committed = self
|
||||||
@@ -527,10 +536,14 @@ impl DefaultObjectUsecase {
|
|||||||
.map_err(api_error_from_s3)?;
|
.map_err(api_error_from_s3)?;
|
||||||
let store = self.object_store().ok_or_else(not_initialized)?;
|
let store = self.object_store().ok_or_else(not_initialized)?;
|
||||||
|
|
||||||
let headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
if ctx.if_absent {
|
||||||
|
headers.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("*"));
|
||||||
|
}
|
||||||
let mut opts =
|
let mut opts =
|
||||||
get_complete_multipart_upload_opts_with_replication_authorization(&headers, false).map_err(ApiError::from)?;
|
get_complete_multipart_upload_opts_with_replication_authorization(&headers, false).map_err(ApiError::from)?;
|
||||||
opts.preserve_etag = ctx.preserve_etag.clone();
|
opts.preserve_etag = ctx.preserve_etag.clone();
|
||||||
|
opts.preserve_delete_marker = ctx.preserve_delete_marker;
|
||||||
let versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await;
|
let versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await;
|
||||||
opts.versioned = versioned;
|
opts.versioned = versioned;
|
||||||
opts.version_suspended = BucketVersioningSys::prefix_suspended(&bucket, &key).await;
|
opts.version_suspended = BucketVersioningSys::prefix_suspended(&bucket, &key).await;
|
||||||
@@ -747,6 +760,8 @@ mod tests {
|
|||||||
size: Some(body.len() as u64),
|
size: Some(body.len() as u64),
|
||||||
expected_md5_hex: Some(md5_hex(body)),
|
expected_md5_hex: Some(md5_hex(body)),
|
||||||
preserve_etag: None,
|
preserve_etag: None,
|
||||||
|
if_absent: false,
|
||||||
|
preserve_delete_marker: false,
|
||||||
content_headers: HashMap::from([
|
content_headers: HashMap::from([
|
||||||
("Content-Type".to_string(), "text/plain".to_string()),
|
("Content-Type".to_string(), "text/plain".to_string()),
|
||||||
("Cache-Control".to_string(), "max-age=60".to_string()),
|
("Cache-Control".to_string(), "max-age=60".to_string()),
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ impl OnDemandMigrationWriteBack {
|
|||||||
.object_store()
|
.object_store()
|
||||||
.ok_or_else(|| WriteBackError::Local("object store is not initialized".to_string()))
|
.ok_or_else(|| WriteBackError::Local("object store is not initialized".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn require_atomic_write_back(&self) -> Result<(), WriteBackError> {
|
||||||
|
if !self.store()?.supports_atomic_create_only_write_back() {
|
||||||
|
return Err(WriteBackError::Unsupported(
|
||||||
|
"write-back requires namespace locking and exactly one pool with one erasure set".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rfc3339(time: OffsetDateTime) -> String {
|
fn rfc3339(time: OffsetDateTime) -> String {
|
||||||
@@ -161,6 +170,8 @@ pub(super) async fn write_back_context(request: &WriteBackRequest, single_part:
|
|||||||
size: Some(head.size),
|
size: Some(head.size),
|
||||||
expected_md5_hex: single_part.then(|| expected_md5_hex(head)).flatten(),
|
expected_md5_hex: single_part.then(|| expected_md5_hex(head)).flatten(),
|
||||||
preserve_etag,
|
preserve_etag,
|
||||||
|
if_absent: true,
|
||||||
|
preserve_delete_marker: request.respect_delete_marker,
|
||||||
content_headers: content_headers(head),
|
content_headers: content_headers(head),
|
||||||
user_metadata: head.user_metadata.clone(),
|
user_metadata: head.user_metadata.clone(),
|
||||||
tags: request.tags.as_ref().and_then(encode_tags),
|
tags: request.tags.as_ref().and_then(encode_tags),
|
||||||
@@ -207,6 +218,7 @@ impl OdmWriteBack for OnDemandMigrationWriteBack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn put_object(&self, request: &WriteBackRequest, body: WriteBackBody) -> Result<WriteBackOutcome, WriteBackError> {
|
async fn put_object(&self, request: &WriteBackRequest, body: WriteBackBody) -> Result<WriteBackOutcome, WriteBackError> {
|
||||||
|
self.require_atomic_write_back()?;
|
||||||
let ctx = write_back_context(request, true).await;
|
let ctx = write_back_context(request, true).await;
|
||||||
self.usecase()
|
self.usecase()
|
||||||
.internal_put_object(ctx, body)
|
.internal_put_object(ctx, body)
|
||||||
@@ -216,6 +228,7 @@ impl OdmWriteBack for OnDemandMigrationWriteBack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create_multipart_upload(&self, request: &WriteBackRequest) -> Result<String, WriteBackError> {
|
async fn create_multipart_upload(&self, request: &WriteBackRequest) -> Result<String, WriteBackError> {
|
||||||
|
self.require_atomic_write_back()?;
|
||||||
let ctx = write_back_context(request, false).await;
|
let ctx = write_back_context(request, false).await;
|
||||||
self.usecase()
|
self.usecase()
|
||||||
.internal_create_multipart_upload(&ctx)
|
.internal_create_multipart_upload(&ctx)
|
||||||
@@ -249,6 +262,7 @@ impl OdmWriteBack for OnDemandMigrationWriteBack {
|
|||||||
upload_id: &str,
|
upload_id: &str,
|
||||||
parts: Vec<WriteBackPart>,
|
parts: Vec<WriteBackPart>,
|
||||||
) -> Result<WriteBackOutcome, WriteBackError> {
|
) -> Result<WriteBackOutcome, WriteBackError> {
|
||||||
|
self.require_atomic_write_back()?;
|
||||||
let ctx = write_back_context(request, false).await;
|
let ctx = write_back_context(request, false).await;
|
||||||
let parts = parts
|
let parts = parts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -334,6 +348,7 @@ mod tests {
|
|||||||
pulled_at: OffsetDateTime::from_unix_timestamp(1_756_800_000).expect("valid timestamp"),
|
pulled_at: OffsetDateTime::from_unix_timestamp(1_756_800_000).expect("valid timestamp"),
|
||||||
preserve_etag: true,
|
preserve_etag: true,
|
||||||
emit_events: true,
|
emit_events: true,
|
||||||
|
respect_delete_marker: true,
|
||||||
tags: Some(HashMap::from([
|
tags: Some(HashMap::from([
|
||||||
("team".to_string(), "storage".to_string()),
|
("team".to_string(), "storage".to_string()),
|
||||||
("env".to_string(), "prod".to_string()),
|
("env".to_string(), "prod".to_string()),
|
||||||
@@ -486,6 +501,33 @@ mod tests {
|
|||||||
assert!(!local.delete_marker);
|
assert!(!local.delete_marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn write_back_rejects_unsupported_topology_before_any_mutation() {
|
||||||
|
let (_dir, _paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
|
||||||
|
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
||||||
|
let bucket = "odm-unsupported";
|
||||||
|
store
|
||||||
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("bucket");
|
||||||
|
let write_back = OnDemandMigrationWriteBack::new();
|
||||||
|
let req = request(bucket, "key", source_head(b"source"));
|
||||||
|
assert!(matches!(
|
||||||
|
write_back.put_object(&req, body_stream(b"source")).await,
|
||||||
|
Err(WriteBackError::Unsupported(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
write_back.create_multipart_upload(&req).await,
|
||||||
|
Err(WriteBackError::Unsupported(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
write_back.complete_multipart_upload(&req, "no-session", Vec::new()).await,
|
||||||
|
Err(WriteBackError::Unsupported(_))
|
||||||
|
));
|
||||||
|
assert_nothing_left(&store, bucket, "key").await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn write_back_integrity_failure_leaves_nothing_behind() {
|
async fn write_back_integrity_failure_leaves_nothing_behind() {
|
||||||
@@ -503,6 +545,133 @@ mod tests {
|
|||||||
assert_nothing_left(&store, &bucket, "wrong.bin").await;
|
assert_nothing_left(&store, &bucket, "wrong.bin").await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn write_back_commit_does_not_overwrite_a_concurrent_client_put() {
|
||||||
|
use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||||
|
for versioned in [false, true] {
|
||||||
|
let (store, bucket) = write_back_test_bucket("odm-wb-race", versioned).await;
|
||||||
|
let source = b"old source bytes";
|
||||||
|
let client = b"new client bytes";
|
||||||
|
let req = request(&bucket, "race", source_head(source));
|
||||||
|
let client_req = request(&bucket, "race", source_head(client));
|
||||||
|
let mut client_ctx = write_back_context(&client_req, true).await;
|
||||||
|
client_ctx.if_absent = false;
|
||||||
|
let client_after = PutObjectCommitBarrier::install(&bucket, "race", PutObjectCommitPause::AfterNamespace);
|
||||||
|
let client_put = tokio::spawn(async move {
|
||||||
|
DefaultObjectUsecase::from_global()
|
||||||
|
.internal_put_object(client_ctx, body_stream(client))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
client_after.wait_until_paused().await;
|
||||||
|
let source_before = PutObjectCommitBarrier::install(&bucket, "race", PutObjectCommitPause::BeforeNamespace);
|
||||||
|
let write_back = OnDemandMigrationWriteBack::new();
|
||||||
|
let (result, ()) = tokio::join!(write_back.put_object(&req, body_stream(source)), async {
|
||||||
|
source_before.wait_until_paused().await;
|
||||||
|
drop(source_before);
|
||||||
|
drop(client_after);
|
||||||
|
});
|
||||||
|
let committed = client_put.await.expect("client task").expect("ordinary client write wins");
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(WriteBackError::Local(ref error)) if error.contains("PreconditionFailed")),
|
||||||
|
"{result:?}"
|
||||||
|
);
|
||||||
|
let stored = stored_object(&store, &bucket, "race").await;
|
||||||
|
assert_eq!(stored.etag, committed.etag);
|
||||||
|
assert_eq!(stored.version_id, committed.version_id);
|
||||||
|
assert_eq!(committed.version_id.is_some(), versioned);
|
||||||
|
assert_eq!(raw_object_bytes(&store, &bucket, "race").await, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn write_back_multipart_completion_preserves_a_client_put_after_staging() {
|
||||||
|
let (store, bucket) = write_back_test_bucket("odm-mpu-race", false).await;
|
||||||
|
let write_back = OnDemandMigrationWriteBack::new();
|
||||||
|
let req = request(&bucket, "race", source_head(b"source"));
|
||||||
|
let upload_id = write_back.create_multipart_upload(&req).await.expect("create");
|
||||||
|
let part = write_back
|
||||||
|
.upload_part(&req, &upload_id, 1, 6, body_stream(b"source"))
|
||||||
|
.await
|
||||||
|
.expect("stage");
|
||||||
|
let mut client_ctx = write_back_context(&request(&bucket, "race", source_head(b"client")), true).await;
|
||||||
|
client_ctx.if_absent = false;
|
||||||
|
let committed = DefaultObjectUsecase::from_global()
|
||||||
|
.internal_put_object(client_ctx, body_stream(b"client"))
|
||||||
|
.await
|
||||||
|
.expect("client put after staging");
|
||||||
|
let result = write_back.complete_multipart_upload(&req, &upload_id, vec![part]).await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(WriteBackError::Local(ref error)) if error.contains("PreconditionFailed")),
|
||||||
|
"{result:?}"
|
||||||
|
);
|
||||||
|
write_back
|
||||||
|
.abort_multipart_upload(&bucket, "race", &upload_id)
|
||||||
|
.await
|
||||||
|
.expect("abort rejected upload");
|
||||||
|
let stored = stored_object(&store, &bucket, "race").await;
|
||||||
|
assert_eq!(stored.etag, committed.etag);
|
||||||
|
assert_eq!(stored.version_id, committed.version_id);
|
||||||
|
assert_eq!(raw_object_bytes(&store, &bucket, "race").await, b"client");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn write_back_preserves_delete_markers_unless_policy_allows_revival() {
|
||||||
|
for multipart in [false, true] {
|
||||||
|
let (store, bucket) = write_back_test_bucket("odm-wb-tombstone", true).await;
|
||||||
|
let write_back = OnDemandMigrationWriteBack::new();
|
||||||
|
let mut req = request(&bucket, "deleted", source_head(b"source"));
|
||||||
|
let staged = if multipart {
|
||||||
|
let id = write_back.create_multipart_upload(&req).await.expect("create");
|
||||||
|
let part = write_back
|
||||||
|
.upload_part(&req, &id, 1, 6, body_stream(b"source"))
|
||||||
|
.await
|
||||||
|
.expect("part");
|
||||||
|
Some((id, part))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.delete_object(
|
||||||
|
&bucket,
|
||||||
|
"deleted",
|
||||||
|
ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("delete marker");
|
||||||
|
let marker = stored_object(&store, &bucket, "deleted").await;
|
||||||
|
assert!(marker.delete_marker);
|
||||||
|
let rejected = if let Some((id, part)) = staged {
|
||||||
|
let result = write_back.complete_multipart_upload(&req, &id, vec![part]).await;
|
||||||
|
write_back
|
||||||
|
.abort_multipart_upload(&bucket, "deleted", &id)
|
||||||
|
.await
|
||||||
|
.expect("abort");
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
write_back.put_object(&req, body_stream(b"source")).await
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
matches!(rejected, Err(WriteBackError::Local(ref error)) if error.contains("PreconditionFailed")),
|
||||||
|
"{rejected:?}"
|
||||||
|
);
|
||||||
|
let retained = stored_object(&store, &bucket, "deleted").await;
|
||||||
|
assert!(retained.delete_marker);
|
||||||
|
assert_eq!(retained.version_id, marker.version_id);
|
||||||
|
req.respect_delete_marker = false;
|
||||||
|
write_back
|
||||||
|
.put_object(&req, body_stream(b"source"))
|
||||||
|
.await
|
||||||
|
.expect("explicit revival policy");
|
||||||
|
assert!(!stored_object(&store, &bucket, "deleted").await.delete_marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn write_back_truncated_stream_leaves_nothing_behind() {
|
async fn write_back_truncated_stream_leaves_nothing_behind() {
|
||||||
|
|||||||
@@ -949,7 +949,11 @@ pub(super) enum PutObjectOrigin<'a> {
|
|||||||
/// request and no credential: managed-SSE authorization treats the write
|
/// request and no credential: managed-SSE authorization treats the write
|
||||||
/// as internal, and the creation event, when requested, names
|
/// as internal, and the creation event, when requested, names
|
||||||
/// `principal_id` instead of an access key.
|
/// `principal_id` instead of an access key.
|
||||||
Internal { principal_id: &'static str, emit_events: bool },
|
Internal {
|
||||||
|
principal_id: &'static str,
|
||||||
|
emit_events: bool,
|
||||||
|
preserve_delete_marker: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PutObjectOrigin<'_> {
|
impl PutObjectOrigin<'_> {
|
||||||
@@ -1602,6 +1606,12 @@ impl DefaultObjectUsecase {
|
|||||||
if let Some(etag) = preserve_etag {
|
if let Some(etag) = preserve_etag {
|
||||||
opts.preserve_etag = Some(etag);
|
opts.preserve_etag = Some(etag);
|
||||||
}
|
}
|
||||||
|
if let PutObjectOrigin::Internal {
|
||||||
|
preserve_delete_marker, ..
|
||||||
|
} = &origin
|
||||||
|
{
|
||||||
|
opts.preserve_delete_marker = *preserve_delete_marker;
|
||||||
|
}
|
||||||
if let Some(quota_check) = quota_check.as_ref() {
|
if let Some(quota_check) = quota_check.as_ref() {
|
||||||
apply_quota_admission(&mut opts, quota_check)?;
|
apply_quota_admission(&mut opts, quota_check)?;
|
||||||
}
|
}
|
||||||
@@ -1768,6 +1778,7 @@ impl DefaultObjectUsecase {
|
|||||||
PutObjectOrigin::Internal {
|
PutObjectOrigin::Internal {
|
||||||
principal_id,
|
principal_id,
|
||||||
emit_events,
|
emit_events,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
let principal_id = *principal_id;
|
let principal_id = *principal_id;
|
||||||
let request_context = request_context::RequestContext::fallback();
|
let request_context = request_context::RequestContext::fallback();
|
||||||
|
|||||||
@@ -891,11 +891,44 @@ pub(crate) fn mark_on_demand_migration_list_local_only(headers: &mut HeaderMap)
|
|||||||
/// forwarded to the source: a 304/412 answered by the source would be
|
/// forwarded to the source: a 304/412 answered by the source would be
|
||||||
/// indistinguishable from a source failure.
|
/// indistinguishable from a source failure.
|
||||||
pub(crate) fn odm_check_source_preconditions(headers: &HeaderMap, head: &SourceHead) -> S3Result<()> {
|
pub(crate) fn odm_check_source_preconditions(headers: &HeaderMap, head: &SourceHead) -> S3Result<()> {
|
||||||
|
let if_match = headers
|
||||||
|
.get(http::header::IF_MATCH)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim);
|
||||||
|
let if_none_match = headers
|
||||||
|
.get(http::header::IF_NONE_MATCH)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim);
|
||||||
|
let needs_etag = if_match.is_some_and(|value| value != "*") || if_none_match.is_some_and(|value| value != "*");
|
||||||
|
let needs_mtime = (!headers.contains_key(http::header::IF_MATCH) && headers.contains_key(http::header::IF_UNMODIFIED_SINCE))
|
||||||
|
|| (!headers.contains_key(http::header::IF_NONE_MATCH) && headers.contains_key(http::header::IF_MODIFIED_SINCE));
|
||||||
|
if (needs_etag && head.etag.is_none()) || (needs_mtime && head.last_modified.is_none()) {
|
||||||
|
return Err(odm_source_unavailable_error("missing_source_validator"));
|
||||||
|
}
|
||||||
let info = ObjectInfo {
|
let info = ObjectInfo {
|
||||||
etag: head.etag.clone(),
|
etag: head.etag.clone(),
|
||||||
mod_time: head.last_modified.map(OffsetDateTime::from),
|
mod_time: head.last_modified.map(OffsetDateTime::from),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// A successful source read establishes wildcard existence, but the
|
||||||
|
// remaining conditions must still run in their ordinary precedence.
|
||||||
|
if head.etag.is_none() && (if_match == Some("*") || if_none_match == Some("*")) {
|
||||||
|
let mut remaining = headers.clone();
|
||||||
|
if if_match == Some("*") {
|
||||||
|
remaining.remove(http::header::IF_MATCH);
|
||||||
|
remaining.remove(http::header::IF_UNMODIFIED_SINCE);
|
||||||
|
}
|
||||||
|
if if_none_match == Some("*") {
|
||||||
|
remaining.remove(http::header::IF_NONE_MATCH);
|
||||||
|
remaining.remove(http::header::IF_MODIFIED_SINCE);
|
||||||
|
}
|
||||||
|
check_preconditions(&remaining, &info)?;
|
||||||
|
return if if_none_match == Some("*") {
|
||||||
|
Err(S3Error::new(S3ErrorCode::NotModified))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
}
|
||||||
check_preconditions(headers, &info)
|
check_preconditions(headers, &info)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1952,8 +1985,42 @@ mod on_demand_migration_tests {
|
|||||||
.expect_err("modified since an earlier date is 412");
|
.expect_err("modified since an earlier date is 412");
|
||||||
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
||||||
|
|
||||||
// A source without validators cannot fail a precondition.
|
|
||||||
let bare = SourceHead::default();
|
let bare = SourceHead::default();
|
||||||
assert!(odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "\"other\""), &bare).is_ok());
|
let err =
|
||||||
|
odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "\"other\""), &bare).expect_err("missing ETag");
|
||||||
|
assert_eq!(err.status_code(), Some(http::StatusCode::FAILED_DEPENDENCY));
|
||||||
|
assert!(odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "*"), &bare).is_ok());
|
||||||
|
let err =
|
||||||
|
odm_check_source_preconditions(&headers_with(http::header::IF_NONE_MATCH, "*"), &bare).expect_err("source exists");
|
||||||
|
assert_eq!(err.code(), &S3ErrorCode::NotModified);
|
||||||
|
let dated = SourceHead {
|
||||||
|
last_modified: head.last_modified,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "*"), &dated).is_ok());
|
||||||
|
let mut combined = headers_with(http::header::IF_NONE_MATCH, "*");
|
||||||
|
combined.insert(http::header::IF_MATCH, HeaderValue::from_static("\"other\""));
|
||||||
|
assert_eq!(
|
||||||
|
odm_check_source_preconditions(&combined, &dated)
|
||||||
|
.expect_err("specific ETag unavailable")
|
||||||
|
.status_code(),
|
||||||
|
Some(http::StatusCode::FAILED_DEPENDENCY)
|
||||||
|
);
|
||||||
|
combined.remove(http::header::IF_MATCH);
|
||||||
|
combined.insert(
|
||||||
|
http::header::IF_UNMODIFIED_SINCE,
|
||||||
|
HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
odm_check_source_preconditions(&combined, &dated)
|
||||||
|
.expect_err("unmodified-since fails before none-match")
|
||||||
|
.code(),
|
||||||
|
&S3ErrorCode::PreconditionFailed
|
||||||
|
);
|
||||||
|
for header in [http::header::IF_MODIFIED_SINCE, http::header::IF_UNMODIFIED_SINCE] {
|
||||||
|
let err = odm_check_source_preconditions(&headers_with(header, "Wed, 21 Oct 2015 07:28:00 GMT"), &bare)
|
||||||
|
.expect_err("missing timestamp");
|
||||||
|
assert_eq!(err.status_code(), Some(http::StatusCode::FAILED_DEPENDENCY));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2056,9 +2056,9 @@ impl Node for NodeService {
|
|||||||
)
|
)
|
||||||
.map_err(|err| Status::failed_precondition(err.to_string()))?;
|
.map_err(|err| Status::failed_precondition(err.to_string()))?;
|
||||||
}
|
}
|
||||||
let namespace_generation = store.scanner_namespace_mutation_generation();
|
|
||||||
let topology_digest = rustfs_scanner::scanner_topology_digest(store.as_ref());
|
let topology_digest = rustfs_scanner::scanner_topology_digest(store.as_ref());
|
||||||
let (data_movement_active, publication_blocked, movement_generation) = store.scanner_data_movement_activity().await;
|
let (data_movement_active, publication_blocked, movement_generation) = store.scanner_data_movement_activity().await;
|
||||||
|
let namespace_generation = store.scanner_namespace_mutation_generation();
|
||||||
let mut response = match request_protocol {
|
let mut response = match request_protocol {
|
||||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION | SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION => {
|
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION | SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION => {
|
||||||
previous_scanner_activity_response(namespace_generation, topology_digest, data_movement_active)
|
previous_scanner_activity_response(namespace_generation, topology_digest, data_movement_active)
|
||||||
@@ -6120,6 +6120,91 @@ mod tests {
|
|||||||
assert_eq!(unavailable.code(), tonic::Code::Unavailable);
|
assert_eq!(unavailable.code(), tonic::Code::Unavailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_activity_samples_namespace_generation_after_waiting_for_movement_state() {
|
||||||
|
use crate::storage::storage_api::{ObjectOptions, PutObjReader, contract::object::ObjectIO as _};
|
||||||
|
|
||||||
|
let _ = rustfs_credentials::set_global_rpc_secret("scanner-activity-generation-test-secret".to_string());
|
||||||
|
let _ = rustfs_credentials::init_global_action_credentials(
|
||||||
|
Some("TESTROOTACCESSKEY".to_string()),
|
||||||
|
Some("TESTROOTSECRET123".to_string()),
|
||||||
|
);
|
||||||
|
let temp_dir = tempfile::tempdir().expect("scanner activity RPC test directory");
|
||||||
|
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||||
|
.base_dir(temp_dir.path())
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
ObjectStore::new(Arc::clone(&env.ecstore))
|
||||||
|
.save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX))
|
||||||
|
.await
|
||||||
|
.expect("seed IAM format");
|
||||||
|
let iam = rustfs_iam::build_iam_sys(Arc::clone(&env.ecstore))
|
||||||
|
.await
|
||||||
|
.expect("build isolated IAM");
|
||||||
|
let context = Arc::new(crate::runtime_sources::AppContext::with_default_interfaces(
|
||||||
|
Arc::clone(&env.ecstore),
|
||||||
|
iam,
|
||||||
|
Arc::new(KmsServiceManager::new()),
|
||||||
|
));
|
||||||
|
let service = make_server_for_context(Some(context));
|
||||||
|
let bucket = "scanner-activity-generation";
|
||||||
|
env.make_bucket(bucket, false).await;
|
||||||
|
let generation_before = env.ecstore.scanner_namespace_mutation_generation();
|
||||||
|
let mut request = Request::new(ScannerActivityRequest {
|
||||||
|
challenge: vec![7; 16].into(),
|
||||||
|
protocol_version: rustfs_scanner::SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||||
|
acknowledge_instance_id: String::new(),
|
||||||
|
acknowledge_dirty_usage_generation: 0,
|
||||||
|
});
|
||||||
|
let canonical = rustfs_protos::canonical_scanner_activity_request_body(request.get_ref())
|
||||||
|
.expect("scanner activity request should encode");
|
||||||
|
set_tonic_canonical_body_digest(&mut request, &canonical).expect("digest metadata should encode");
|
||||||
|
mark_v2_authenticated(&mut request);
|
||||||
|
|
||||||
|
let pool_meta = env.ecstore.pool_meta.write().await;
|
||||||
|
drop(
|
||||||
|
env.ecstore
|
||||||
|
.decommission_cancelers
|
||||||
|
.try_write()
|
||||||
|
.expect("movement snapshot should not hold the cancelers before the RPC"),
|
||||||
|
);
|
||||||
|
let mut activity = Box::pin(tokio::task::unconstrained(service.scanner_activity(request)));
|
||||||
|
assert!(futures::poll!(activity.as_mut()).is_pending());
|
||||||
|
assert!(
|
||||||
|
env.ecstore.decommission_cancelers.try_write().is_err(),
|
||||||
|
"the RPC must hold the cancelers read guard while waiting for pool metadata"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select the existing set directly: ECStore pool selection reads the lock held by this test.
|
||||||
|
let mut reader = PutObjReader::from_vec(b"namespace changed during activity probe".to_vec());
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
env.ecstore.pools[0].disk_set[0].put_object(
|
||||||
|
bucket,
|
||||||
|
"object",
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the namespace mutation must not wait for the RPC's pool lock")
|
||||||
|
.expect("the namespace mutation must complete while the RPC waits");
|
||||||
|
let generation_after = env.ecstore.scanner_namespace_mutation_generation();
|
||||||
|
assert!(generation_after > generation_before);
|
||||||
|
drop(pool_meta);
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(Duration::from_secs(30), activity)
|
||||||
|
.await
|
||||||
|
.expect("scanner activity RPC should resume after the pool lock is released")
|
||||||
|
.expect("authenticated scanner activity RPC should succeed")
|
||||||
|
.into_inner();
|
||||||
|
assert_eq!(response.namespace_generation, generation_after);
|
||||||
|
assert_eq!(response.publication_blocked, Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_scanner_dirty_usage_snapshot_requires_body_bound_auth_and_signs_a_consistent_view() {
|
async fn test_scanner_dirty_usage_snapshot_requires_body_bound_auth_and_signs_a_consistent_view() {
|
||||||
let _ = rustfs_credentials::set_global_rpc_secret("scanner-dirty-usage-snapshot-test-secret".to_string());
|
let _ = rustfs_credentials::set_global_rpc_secret("scanner-dirty-usage-snapshot-test-secret".to_string());
|
||||||
|
|||||||
Reference in New Issue
Block a user