mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-30 08:49:26 +00:00
fix(scanner): fence movement generation publication (#6461)
* feat(scanner): add movement generation fencing * fix(scanner): prioritize unverified cycle deferral * feat(ecstore): add scanner publication lease fence * feat(rpc): add scanner publication lease protocol * feat(scanner): hold remote leases through usage publish * test(scanner): cover publication lease fencing * fix(scanner): fence remote leases across restart and delay * feat(rpc): fence scanner publication rename writes * fix(scanner): fence observed cleanup deletes * fix(proto): qualify lease release test types * fix(scanner): pin movement notifications * fix(scanner): clean publication imports * fix(ecstore): satisfy scanner fence clippy * refactor(scanner): group wait and publication options * fix(scanner): satisfy final lint and facade guards * fix(rpc): resolve facade export conflicts * fix(ci): remove unused decommission and healing facades * fix(ci): cfg-gate test-only usage overlay import * fix(scanner): wake on remote scanner restart
This commit is contained in:
@@ -2994,6 +2994,7 @@ mod tests {
|
||||
dst_volume: "bucket".to_string(),
|
||||
dst_path: "object".to_string(),
|
||||
file_info_bin: vec![0x81, 0xA1, 0x76, 0x01].into(),
|
||||
scanner_publication_lease_token: Vec::new().into(),
|
||||
};
|
||||
let body = rustfs_protos::canonical_rename_data_request_body(&message).expect("small request should encode");
|
||||
let mut request = tonic::Request::new(());
|
||||
|
||||
@@ -48,7 +48,7 @@ pub use internode_data_transport::build_internode_data_transport_from_env;
|
||||
pub(crate) use peer_rest_client::TierConfigReloadOutcome;
|
||||
pub use peer_rest_client::{
|
||||
KMS_SIGNAL_SUBSYSTEM, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG,
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity,
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity, ScannerPublicationLease,
|
||||
};
|
||||
pub(crate) use peer_s3_client::heal_bucket_local_on_disks;
|
||||
pub use peer_s3_client::{
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_bo
|
||||
use crate::error::{Error, Result};
|
||||
use crate::storage_api_contracts::internode::{
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION,
|
||||
};
|
||||
use crate::{
|
||||
bucket::replication::BucketStats,
|
||||
@@ -45,8 +46,9 @@ use rustfs_protos::proto_gen::node_service::{
|
||||
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
|
||||
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
|
||||
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
|
||||
ScannerActivityRequest, ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse,
|
||||
StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
|
||||
ScannerActivityRequest, ScannerActivityResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest,
|
||||
ScannerPublicationLeaseResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
|
||||
StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
|
||||
TierMutationControlResponse, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
|
||||
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
||||
};
|
||||
@@ -84,6 +86,11 @@ const HEAL_CONTROL_PAYLOAD_MAX_SIZE: usize = 64 * 1024;
|
||||
const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
|
||||
const PEER_REST_RECOVERY_MAX_BACKOFF: Duration = Duration::from_secs(30);
|
||||
const SCANNER_ACTIVITY_MAX_MESSAGE_SIZE: usize = 1024;
|
||||
/// Reserve time for the acquire response's network/clock uncertainty. The
|
||||
/// server owns the real expiry; this local deadline is intentionally earlier
|
||||
/// so a coordinator never starts a bounded persistence operation at the edge
|
||||
/// of a remote lease.
|
||||
const SCANNER_PUBLICATION_LEASE_SAFETY_MARGIN: Duration = Duration::from_secs(5);
|
||||
const REPLICATION_STATS_MAX_MESSAGE_SIZE: usize = 8 * 1024 * 1024;
|
||||
const BUCKET_METADATA_RELOAD_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
@@ -150,6 +157,8 @@ pub struct ScannerPeerActivity {
|
||||
pub data_movement_active: Option<bool>,
|
||||
pub dirty_usage_generation: Option<u64>,
|
||||
pub dirty_usage_pending: Option<bool>,
|
||||
pub movement_generation: Option<u64>,
|
||||
pub publication_blocked: Option<bool>,
|
||||
}
|
||||
|
||||
fn decode_scanner_activity_with_verifier(
|
||||
@@ -166,7 +175,14 @@ fn decode_scanner_activity_with_verifier(
|
||||
{
|
||||
return Err(Error::other("peer returned an invalid scanner activity instance ID"));
|
||||
}
|
||||
let (topology_digest, data_movement_active, dirty_usage_generation, dirty_usage_pending) = match response.protocol_version {
|
||||
let (
|
||||
topology_digest,
|
||||
data_movement_active,
|
||||
dirty_usage_generation,
|
||||
dirty_usage_pending,
|
||||
movement_generation,
|
||||
publication_blocked,
|
||||
) = match response.protocol_version {
|
||||
// RUSTFS_COMPAT_TODO(ns-scanner-rpc-v3): legacy response fields are unauthenticated. Remove after protocol v0 peers are unsupported.
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION
|
||||
if response.topology_digest.is_empty()
|
||||
@@ -175,7 +191,7 @@ fn decode_scanner_activity_with_verifier(
|
||||
&& response.dirty_usage_generation == 0
|
||||
&& !response.dirty_usage_pending =>
|
||||
{
|
||||
(None, None, None, None)
|
||||
(None, None, None, None, None, None)
|
||||
}
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION => {
|
||||
return Err(Error::other("legacy scanner activity peer returned unexpected extended fields"));
|
||||
@@ -198,9 +214,11 @@ fn decode_scanner_activity_with_verifier(
|
||||
Some(response.data_movement_active),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION => {
|
||||
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION => {
|
||||
if response.dirty_usage_pending && response.dirty_usage_generation == 0 {
|
||||
return Err(Error::other("scanner activity peer returned pending dirty usage without a generation"));
|
||||
}
|
||||
@@ -218,11 +236,42 @@ fn decode_scanner_activity_with_verifier(
|
||||
Some(response.data_movement_active),
|
||||
Some(response.dirty_usage_generation),
|
||||
Some(response.dirty_usage_pending),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
version => {
|
||||
return Err(Error::other(format!("peer returned unsupported scanner activity protocol {version}")));
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION => {
|
||||
if response.dirty_usage_pending && response.dirty_usage_generation == 0 {
|
||||
return Err(Error::other("scanner activity peer returned pending dirty usage without a generation"));
|
||||
}
|
||||
let movement_generation = response
|
||||
.movement_generation
|
||||
.ok_or_else(|| Error::other("scanner activity peer omitted its movement generation"))?;
|
||||
let publication_blocked = response
|
||||
.publication_blocked
|
||||
.ok_or_else(|| Error::other("scanner activity peer omitted its publication blocked state"))?;
|
||||
if movement_generation == u64::MAX {
|
||||
return Err(Error::other("scanner activity peer exhausted its movement generation"));
|
||||
}
|
||||
let canonical = rustfs_protos::canonical_scanner_activity_v7_response_body(challenge, &response)
|
||||
.map_err(|_| Error::other("scanner activity peer response is too large to authenticate"))?;
|
||||
verify_proof(&canonical, &response.response_proof)?;
|
||||
(
|
||||
Some(
|
||||
response
|
||||
.topology_digest
|
||||
.as_ref()
|
||||
.try_into()
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner topology digest"))?,
|
||||
),
|
||||
Some(response.data_movement_active),
|
||||
Some(response.dirty_usage_generation),
|
||||
Some(response.dirty_usage_pending),
|
||||
Some(movement_generation),
|
||||
Some(publication_blocked),
|
||||
)
|
||||
}
|
||||
version => return Err(Error::other(format!("peer returned unsupported scanner activity protocol {version}"))),
|
||||
};
|
||||
Ok(ScannerPeerActivity {
|
||||
instance_id: response.instance_id,
|
||||
@@ -233,6 +282,8 @@ fn decode_scanner_activity_with_verifier(
|
||||
data_movement_active,
|
||||
dirty_usage_generation,
|
||||
dirty_usage_pending,
|
||||
movement_generation,
|
||||
publication_blocked,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,6 +294,17 @@ fn decode_scanner_activity(response: ScannerActivityResponse, challenge: &[u8; 1
|
||||
})
|
||||
}
|
||||
|
||||
fn scanner_activity_protocol_unsupported(err: &Error) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
Error::Io(io_err)
|
||||
if embedded_tonic_status(io_err).is_some_and(|status| {
|
||||
status.code() == tonic::Code::FailedPrecondition
|
||||
&& status.message().starts_with("unsupported scanner activity request protocol")
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_heal_control_capability_proof(canonical_ack: &[u8], proof: &[u8]) -> Result<()> {
|
||||
verify_tonic_rpc_response_proof(canonical_ack, proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid heal control capability proof"))
|
||||
@@ -285,6 +347,76 @@ pub struct PeerLiveEventsBatch {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScannerPublicationLease {
|
||||
pub token: Uuid,
|
||||
pub movement_generation: u64,
|
||||
/// Stable storage owner identity. This is distinct from the activity
|
||||
/// session and is bound into both acquire and release proofs.
|
||||
pub owner_id: String,
|
||||
/// Process/session nonce observed by the final activity probe.
|
||||
pub session_id: String,
|
||||
pub expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl ScannerPublicationLease {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
std::time::Instant::now() < self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_scanner_publication_lease_response_fields(
|
||||
response: &ScannerPublicationLeaseResponse,
|
||||
expected_session_id: &str,
|
||||
expected_generation: u64,
|
||||
) -> Result<(Uuid, String)> {
|
||||
if !response.success {
|
||||
return Err(Error::other(
|
||||
response
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|error| error.error_info.clone())
|
||||
.unwrap_or_else(|| "peer rejected scanner publication lease".to_string()),
|
||||
));
|
||||
}
|
||||
if response.movement_generation != expected_generation {
|
||||
return Err(Error::other("peer returned a different scanner publication lease generation"));
|
||||
}
|
||||
if response.session_id != expected_session_id {
|
||||
return Err(Error::other("peer returned a different scanner publication lease session"));
|
||||
}
|
||||
let owner_id = Uuid::parse_str(&response.owner_id)
|
||||
.ok()
|
||||
.filter(|owner_id| !owner_id.is_nil())
|
||||
.map(|owner_id| owner_id.to_string())
|
||||
.ok_or_else(|| Error::other("peer returned an invalid scanner publication lease owner"))?;
|
||||
if response.lease_ttl_ms != crate::store::SCANNER_PUBLICATION_LEASE_TTL_MS {
|
||||
return Err(Error::other("peer returned an unsupported scanner publication lease TTL"));
|
||||
}
|
||||
let token = Uuid::from_slice(response.token.as_ref())
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner publication lease token"))?;
|
||||
Ok((token, owner_id))
|
||||
}
|
||||
|
||||
fn scanner_publication_lease_deadline(
|
||||
request_started: std::time::Instant,
|
||||
response_received: std::time::Instant,
|
||||
lease_ttl_ms: u64,
|
||||
) -> Result<std::time::Instant> {
|
||||
let lease_window = Duration::from_millis(lease_ttl_ms)
|
||||
.checked_sub(SCANNER_PUBLICATION_LEASE_SAFETY_MARGIN)
|
||||
.ok_or_else(|| Error::other("scanner publication lease TTL is shorter than its safety margin"))?;
|
||||
let elapsed = response_received
|
||||
.checked_duration_since(request_started)
|
||||
.ok_or_else(|| Error::other("scanner publication lease response clock moved backwards"))?;
|
||||
if elapsed >= lease_window {
|
||||
return Err(Error::other("scanner publication lease response arrived after its safety window"));
|
||||
}
|
||||
request_started
|
||||
.checked_add(lease_window)
|
||||
.ok_or_else(|| Error::other("scanner publication lease deadline overflowed"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PeerRestClient {
|
||||
pub host: XHost,
|
||||
@@ -1640,10 +1772,11 @@ impl PeerRestClient {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn scanner_activity_request(
|
||||
async fn scanner_activity_request_with_protocol(
|
||||
&self,
|
||||
acknowledge_instance_id: String,
|
||||
acknowledge_dirty_usage_generation: u64,
|
||||
protocol_version: u32,
|
||||
) -> Result<ScannerPeerActivity> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
@@ -1655,7 +1788,7 @@ impl PeerRestClient {
|
||||
.max_encoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE);
|
||||
let mut request = Request::new(ScannerActivityRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
protocol_version,
|
||||
acknowledge_instance_id,
|
||||
acknowledge_dirty_usage_generation,
|
||||
});
|
||||
@@ -1671,11 +1804,168 @@ impl PeerRestClient {
|
||||
}
|
||||
|
||||
pub async fn scanner_activity(&self) -> Result<ScannerPeerActivity> {
|
||||
self.scanner_activity_request(String::new(), 0).await
|
||||
let result = self
|
||||
.scanner_activity_request_with_protocol(String::new(), 0, SCANNER_ACTIVITY_PROTOCOL_VERSION)
|
||||
.await;
|
||||
if result.as_ref().err().is_some_and(scanner_activity_protocol_unsupported) {
|
||||
// A v6 peer cannot parse the v7 marker. Its authenticated
|
||||
// response is still decoded as untrusted terminal state, so the
|
||||
// scanner will defer publication until every peer is v7.
|
||||
self.scanner_activity_request_with_protocol(String::new(), 0, SCANNER_ACTIVITY_V6_PROTOCOL_VERSION)
|
||||
.await
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn acknowledge_scanner_dirty_usage(&self, instance_id: String, generation: u64) -> Result<ScannerPeerActivity> {
|
||||
self.scanner_activity_request(instance_id, generation).await
|
||||
let result = self
|
||||
.scanner_activity_request_with_protocol(instance_id.clone(), generation, SCANNER_ACTIVITY_PROTOCOL_VERSION)
|
||||
.await;
|
||||
if result.as_ref().err().is_some_and(scanner_activity_protocol_unsupported) {
|
||||
self.scanner_activity_request_with_protocol(instance_id, generation, SCANNER_ACTIVITY_V6_PROTOCOL_VERSION)
|
||||
.await
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire a bounded, storage-owned read admission on the peer that
|
||||
/// produced the final activity generation. Older peers do not implement
|
||||
/// the lease form and are rejected rather than downgraded.
|
||||
pub async fn acquire_scanner_publication_lease(
|
||||
&self,
|
||||
expected_session_id: &str,
|
||||
expected_generation: u64,
|
||||
) -> Result<ScannerPublicationLease> {
|
||||
let request_started = std::time::Instant::now();
|
||||
self.finalize_result(
|
||||
async {
|
||||
let challenge = Uuid::new_v4();
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await?
|
||||
.max_decoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE)
|
||||
.max_encoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE);
|
||||
let mut request = Request::new(ScannerPublicationLeaseRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
expected_movement_generation: expected_generation,
|
||||
ttl_ms: crate::store::SCANNER_PUBLICATION_LEASE_TTL_MS,
|
||||
expected_session_id: expected_session_id.to_string(),
|
||||
token: Bytes::new(),
|
||||
});
|
||||
let canonical = rustfs_protos::canonical_scanner_publication_lease_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("scanner publication lease request is too large to authenticate"))?;
|
||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
||||
let response = client.acquire_scanner_publication_lease(request).await?.into_inner();
|
||||
let response_body =
|
||||
rustfs_protos::canonical_scanner_publication_lease_response_body(challenge.as_bytes(), &response)
|
||||
.map_err(|_| Error::other("scanner publication lease response is too large to authenticate"))?;
|
||||
verify_tonic_rpc_response_proof(&response_body, &response.response_proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner publication lease proof"))?;
|
||||
let (token, owner_id) =
|
||||
validate_scanner_publication_lease_response_fields(&response, expected_session_id, expected_generation)?;
|
||||
Ok(ScannerPublicationLease {
|
||||
token,
|
||||
movement_generation: response.movement_generation,
|
||||
owner_id,
|
||||
session_id: response.session_id,
|
||||
expires_at: scanner_publication_lease_deadline(
|
||||
request_started,
|
||||
std::time::Instant::now(),
|
||||
response.lease_ttl_ms,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Revalidate the exact token immediately before the coordinator commits
|
||||
/// its final publication. The peer keeps the original movement read
|
||||
/// guard in its token table; a restart drops that table and changes the
|
||||
/// activity session, so this proof fails closed instead of accepting an
|
||||
/// ABA generation value.
|
||||
pub async fn validate_scanner_publication_lease(&self, lease: &ScannerPublicationLease) -> Result<()> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
let challenge = Uuid::new_v4();
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await?
|
||||
.max_decoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE)
|
||||
.max_encoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE);
|
||||
let mut request = Request::new(ScannerPublicationLeaseRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
expected_movement_generation: lease.movement_generation,
|
||||
ttl_ms: crate::store::SCANNER_PUBLICATION_LEASE_TTL_MS,
|
||||
expected_session_id: lease.session_id.clone(),
|
||||
token: lease.token.as_bytes().to_vec().into(),
|
||||
});
|
||||
let canonical = rustfs_protos::canonical_scanner_publication_lease_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("scanner publication lease validation request is too large to authenticate"))?;
|
||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
||||
let response = client.acquire_scanner_publication_lease(request).await?.into_inner();
|
||||
let response_body =
|
||||
rustfs_protos::canonical_scanner_publication_lease_response_body(challenge.as_bytes(), &response).map_err(
|
||||
|_| Error::other("scanner publication lease validation response is too large to authenticate"),
|
||||
)?;
|
||||
verify_tonic_rpc_response_proof(&response_body, &response.response_proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner publication lease validation proof"))?;
|
||||
let (token, owner_id) =
|
||||
validate_scanner_publication_lease_response_fields(&response, &lease.session_id, lease.movement_generation)?;
|
||||
if token != lease.token {
|
||||
return Err(Error::other("peer returned a different scanner publication lease token"));
|
||||
}
|
||||
if owner_id != lease.owner_id {
|
||||
return Err(Error::other("peer returned a different scanner publication lease owner"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn release_scanner_publication_lease(&self, lease: &ScannerPublicationLease) -> Result<()> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
let challenge = Uuid::new_v4();
|
||||
let mut client = self.get_client().await?;
|
||||
let mut request = Request::new(ScannerPublicationLeaseReleaseRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
token: lease.token.as_bytes().to_vec().into(),
|
||||
owner_id: lease.owner_id.clone(),
|
||||
session_id: lease.session_id.clone(),
|
||||
});
|
||||
let canonical = rustfs_protos::canonical_scanner_publication_lease_release_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("scanner publication lease release request is too large to authenticate"))?;
|
||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
||||
let request_body = request.get_ref().clone();
|
||||
let response = client.release_scanner_publication_lease(request).await?.into_inner();
|
||||
let response_body = rustfs_protos::canonical_scanner_publication_lease_release_response_body(
|
||||
challenge.as_bytes(),
|
||||
&request_body,
|
||||
&response,
|
||||
)
|
||||
.map_err(|_| Error::other("scanner publication lease release response is too large to authenticate"))?;
|
||||
verify_tonic_rpc_response_proof(&response_body, &response.response_proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner publication lease release proof"))?;
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::other(
|
||||
response
|
||||
.error
|
||||
.map(|error| error.error_info)
|
||||
.unwrap_or_else(|| "peer rejected scanner publication lease release".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_metacache_listing(&self) -> Result<()> {
|
||||
@@ -1991,6 +2281,52 @@ mod tests {
|
||||
use temp_env::async_with_vars;
|
||||
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
|
||||
|
||||
#[test]
|
||||
fn scanner_publication_lease_response_rejects_stale_generation_and_session() {
|
||||
let token = Uuid::new_v4();
|
||||
let response = ScannerPublicationLeaseResponse {
|
||||
success: true,
|
||||
token: token.as_bytes().to_vec().into(),
|
||||
movement_generation: 7,
|
||||
lease_ttl_ms: crate::store::SCANNER_PUBLICATION_LEASE_TTL_MS,
|
||||
error: None,
|
||||
response_proof: Bytes::new(),
|
||||
owner_id: Uuid::new_v4().to_string(),
|
||||
session_id: "session-a".to_string(),
|
||||
};
|
||||
|
||||
assert!(validate_scanner_publication_lease_response_fields(&response, "session-a", 7).is_ok());
|
||||
|
||||
let stale_generation = ScannerPublicationLeaseResponse {
|
||||
movement_generation: 6,
|
||||
..response.clone()
|
||||
};
|
||||
let error = validate_scanner_publication_lease_response_fields(&stale_generation, "session-a", 7)
|
||||
.expect_err("a response from an older movement generation must be rejected");
|
||||
assert!(error.to_string().contains("different scanner publication lease generation"));
|
||||
|
||||
let stale_session = ScannerPublicationLeaseResponse {
|
||||
session_id: "session-b".to_string(),
|
||||
..response
|
||||
};
|
||||
let error = validate_scanner_publication_lease_response_fields(&stale_session, "session-a", 7)
|
||||
.expect_err("a response from an older scanner session must be rejected");
|
||||
assert!(error.to_string().contains("different scanner publication lease session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_publication_lease_deadline_accounts_for_delayed_rpc_response() {
|
||||
let started = std::time::Instant::now();
|
||||
let expected_deadline = started + Duration::from_secs(55);
|
||||
let deadline = scanner_publication_lease_deadline(started, started + Duration::from_secs(10), 60_000)
|
||||
.expect("a response inside the safety window should retain the original deadline");
|
||||
assert_eq!(deadline, expected_deadline);
|
||||
|
||||
let error = scanner_publication_lease_deadline(started, started + Duration::from_secs(55), 60_000)
|
||||
.expect_err("a response arriving at the safety boundary must fail closed");
|
||||
assert!(error.to_string().contains("after its safety window"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_stats_response_decodes_valid_empty_provider() {
|
||||
let mut stats = BucketStats::default();
|
||||
@@ -2229,6 +2565,8 @@ mod tests {
|
||||
response_proof: Vec::new().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
})
|
||||
.expect("legacy peers should retain their activity generations during a rolling upgrade");
|
||||
assert_eq!(
|
||||
@@ -2242,6 +2580,8 @@ mod tests {
|
||||
data_movement_active: None,
|
||||
dirty_usage_generation: None,
|
||||
dirty_usage_pending: None,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2255,6 +2595,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
})
|
||||
.expect("protocol v4 peers should remain observable during a rolling upgrade");
|
||||
assert_eq!(
|
||||
@@ -2268,9 +2610,29 @@ mod tests {
|
||||
data_movement_active: Some(true),
|
||||
dirty_usage_generation: None,
|
||||
dirty_usage_pending: None,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
}
|
||||
);
|
||||
|
||||
let v6 = decode_test_scanner_activity(ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_V6_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: true,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
})
|
||||
.expect("v6 peers should remain readable without a v7 publication proof");
|
||||
assert_eq!(v6.movement_generation, None);
|
||||
assert_eq!(v6.publication_blocked, None);
|
||||
assert_eq!(v6.dirty_usage_generation, Some(11));
|
||||
|
||||
let malformed_topology = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
@@ -2281,6 +2643,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(malformed_topology)
|
||||
@@ -2299,6 +2663,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(missing_instance)
|
||||
@@ -2317,6 +2683,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(malformed_instance)
|
||||
@@ -2335,6 +2703,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
})
|
||||
.expect("complete activity responses should be accepted");
|
||||
assert_eq!(
|
||||
@@ -2348,9 +2718,31 @@ mod tests {
|
||||
data_movement_active: Some(true),
|
||||
dirty_usage_generation: Some(11),
|
||||
dirty_usage_pending: Some(true),
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
}
|
||||
);
|
||||
|
||||
let missing_movement_generation = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(missing_movement_generation)
|
||||
.expect_err("v7 activity must carry movement generation")
|
||||
.to_string()
|
||||
.contains("movement generation")
|
||||
);
|
||||
|
||||
let pending_without_generation = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
@@ -2361,6 +2753,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(pending_without_generation)
|
||||
@@ -2379,6 +2773,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(previous_with_dirty_usage)
|
||||
@@ -2397,6 +2793,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(legacy_with_topology)
|
||||
@@ -2415,6 +2813,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(unsupported_protocol)
|
||||
@@ -2433,6 +2833,8 @@ mod tests {
|
||||
response_proof: Vec::new().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(missing_proof)
|
||||
|
||||
@@ -1981,6 +1981,20 @@ impl RemoteDisk {
|
||||
fi: &FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
self.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn rename_data_borrowed_with_fence(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
fi: &FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
scanner_publication_lease_token: Option<Uuid>,
|
||||
) -> Result<RenameDataResp> {
|
||||
trace!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
@@ -2013,9 +2027,18 @@ impl RemoteDisk {
|
||||
dst_volume: dst_volume.to_string(),
|
||||
dst_path: dst_path.to_string(),
|
||||
file_info_bin: file_info_bin.into(),
|
||||
scanner_publication_lease_token: scanner_publication_lease_token
|
||||
.map(|token| token.as_bytes().to_vec().into())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
|
||||
if scanner_publication_lease_token.is_some() {
|
||||
let canonical_body =
|
||||
canonical_body.map_err(|_| Error::other("rename_data request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
} else {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
|
||||
}
|
||||
|
||||
let response = client.rename_data(request).await?.into_inner();
|
||||
|
||||
@@ -2035,6 +2058,70 @@ impl RemoteDisk {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a path while binding the target-side operation to a scanner
|
||||
/// publication lease. The ordinary `DiskAPI::delete` path keeps the
|
||||
/// legacy digest/compatibility behavior by passing no token.
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn delete_with_scanner_publication_lease(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
opt: DeleteOptions,
|
||||
scanner_publication_lease_token: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
trace!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
volume,
|
||||
path,
|
||||
recursive = opt.recursive,
|
||||
immediate = opt.immediate,
|
||||
fenced = scanner_publication_lease_token.is_some(),
|
||||
op = "delete",
|
||||
state = "started",
|
||||
"Remote disk RPC started"
|
||||
);
|
||||
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let options = serde_json::to_string(&opt)?;
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
|
||||
let mut request = Request::new(DeleteRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
options,
|
||||
scanner_publication_lease_token: scanner_publication_lease_token
|
||||
.map(|token| token.as_bytes().to_vec().into())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_delete_request_body(request.get_ref());
|
||||
if scanner_publication_lease_token.is_some() {
|
||||
let canonical_body =
|
||||
canonical_body.map_err(|_| Error::other("delete request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
} else {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "delete")?;
|
||||
}
|
||||
|
||||
let response = client.delete(request).await?.into_inner();
|
||||
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -3446,47 +3533,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn delete(&self, volume: &str, path: &str, opt: DeleteOptions) -> Result<()> {
|
||||
trace!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
volume,
|
||||
path,
|
||||
recursive = opt.recursive,
|
||||
immediate = opt.immediate,
|
||||
op = "delete",
|
||||
state = "started",
|
||||
"Remote disk RPC started"
|
||||
);
|
||||
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let options = serde_json::to_string(&opt)?;
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
|
||||
let mut request = Request::new(DeleteRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
options,
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_delete_request_body(request.get_ref());
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "delete")?;
|
||||
|
||||
let response = client.delete(request).await?.into_inner();
|
||||
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
self.delete_with_scanner_publication_lease(volume, path, opt, None).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
|
||||
Reference in New Issue
Block a user