mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-11 23:56:53 +00:00
fix(replication): probe the version-identity contract in replication-check (#5881)
* test(replication): pin the version-fidelity probe contract (red) P1-19 (rustfs/backlog#1675 B2): the supported replication contract is targets that adopt the source version id — a target that mints its own ids silently breaks every version-addressed operation that follows (version deletes, heal re-drives never match), diverging the two sides with no signal. replication-check already captures the probe PUT's response version id but never compares it. Red evidence (current main): against a FakeS3Target with assign_own_version_ids enabled, ?replication-check returns Status "OK" — the drift is invisible. test_replication_check_flags_version_minting_target expects a VersionFidelity phase that fails with the machine-readable code BucketRemoteTargetVersionMismatch, skips the later mutation phases, and still cleans up the probe via the version id the target actually assigned. Test infra: FakeS3Target gains assign_own_version_ids (models a generic S3 service; validated-but-not-mirrored source version headers) and a prefix+max-keys ListObjectVersions implementation (the probe key allocation requires it); stored_versions accessor duplicated from the P1-21 branch (identical code, resolves clean on merge). * fix(replication): probe the version-identity contract in replication-check P1-19 (rustfs/backlog#1675 B2, plan B). Replication only converges on targets that adopt the source version id: version-addressed deletes and heal re-drives address the source id, so a target that mints its own ids silently diverges — nothing surfaced this. replication-check already captured the probe PUT's response version id but never compared it. - The probe PUT now carries the source version as `?versionId=` (the exact shape live replication uses since P0-5, and the only shape MinIO consumes; the internal source-version-id header alone would let the probe pass against targets the real data path drifts on). Reuses ecstore's append_version_id_query through the api facade. - New VersionFidelity phase: the probe PUT's response version id must equal the sent source id. On mismatch the phase fails with the machine-readable extension key `"Code": "BucketRemoteTargetVersionMismatch"` (new optional Code field on phase statuses; Go decoders ignore unknown keys), the overall target fails, the later version-addressed mutation phases are skipped, and cleanup still removes the probe via the id the target actually assigned (with the existing list-based sweep as backstop when the target returns no version id at all). - Runtime half: TargetClient::put_object now returns the assigned version id (mirroring remove_object), and the replication PUT path audits it — every drifting PUT increments rustfs_replication_version_identity_drift_total and the first drift per target ARN logs a structured warning pointing at ?replication-check. The drift judgment is a pure function with an exemption-matrix test (empty / literal "null" / nil-uuid sources carry no contract). - docs/operations/replication-check.md documents the phase and the code. Red -> green: test_replication_check_flags_version_minting_target (fake target with assign_own_version_ids; on main the check reported Status "OK"). The probe's query shape is pinned by a journal assertion (revert of the query hunk alone fails it), probe-level unit tests cover the mismatch/mirror matrix including cleanup addressing the minted id, and the existing success e2e now asserts VersionFidelity OK against a RustFS target. Adversarial review (seven roles): non-blocking; noted follow-ups are the multipart runtime audit (the probe phase already pins the contract) and per-target re-warning after reconfiguration. * fix(e2e): stop the fake target self-deadlocking on version-id minting The assign_own_version_ids flag was read with a fresh `lock(&self.store)` inside two paths that already hold that guard — delete_object's marker-creation branch and create_multipart_upload — and the store mutex is not reentrant, so both hung forever (CI: the fake target's own multipart and delete-marker tests ran >1560s until the job was cancelled). Read the flag from the live guard instead. The replication e2e paths did not catch this: a version-addressed purge DELETE never mints an id, and the probe PUT reads the flag before taking the guard. * chore(test): refresh the nextest replication count invariant The e2e-smoke/e2e-repl-nightly split comment is descriptive metadata (authority: `cargo nextest list`); refresh it to this branch's post-rebase total.
This commit is contained in:
@@ -30,10 +30,10 @@ use s3s::access::{S3Access, S3AccessContext};
|
||||
use s3s::auth::SimpleAuth;
|
||||
use s3s::dto::{
|
||||
AbortMultipartUploadInput, AbortMultipartUploadOutput, CompleteMultipartUploadInput, CompleteMultipartUploadOutput,
|
||||
CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteObjectInput, DeleteObjectOutput, ETag,
|
||||
CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteMarkerEntry, DeleteObjectInput, DeleteObjectOutput, ETag,
|
||||
GetBucketVersioningInput, GetBucketVersioningOutput, GetObjectInput, GetObjectOutput, HeadBucketInput, HeadBucketOutput,
|
||||
HeadObjectInput, HeadObjectOutput, PutObjectInput, PutObjectOutput, StreamingBlob, Timestamp, TimestampFormat,
|
||||
UploadPartInput, UploadPartOutput,
|
||||
HeadObjectInput, HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ObjectVersionId, PutObjectInput,
|
||||
PutObjectOutput, StreamingBlob, Timestamp, TimestampFormat, UploadPartInput, UploadPartOutput,
|
||||
};
|
||||
use s3s::service::{S3Service, S3ServiceBuilder};
|
||||
use s3s::validation::{AwsNameValidation, NameValidation};
|
||||
@@ -91,6 +91,7 @@ pub enum Operation {
|
||||
GetObject,
|
||||
HeadObject,
|
||||
DeleteObject,
|
||||
ListObjectVersions,
|
||||
CreateMultipartUpload,
|
||||
UploadPart,
|
||||
CompleteMultipartUpload,
|
||||
@@ -109,6 +110,8 @@ pub enum FaultAction {
|
||||
/// already have buffered the rest of the current frame; the journal reports
|
||||
/// the threshold, and the backend never receives or stores the request.
|
||||
DisconnectAfterBytes(usize),
|
||||
/// Apply the request, then close the connection before returning its response.
|
||||
DisconnectAfterResponse,
|
||||
/// Drain a request body in fixed-size slices, sleeping after every slice.
|
||||
SlowDrain { chunk_bytes: usize, delay: Duration },
|
||||
/// Store the request normally but replace the response ETag.
|
||||
@@ -142,6 +145,7 @@ struct ControlState {
|
||||
#[derive(Default)]
|
||||
struct StoreState {
|
||||
assign_own_version_ids: bool,
|
||||
assign_own_multipart_version_ids: bool,
|
||||
buckets: HashMap<String, BucketState>,
|
||||
uploads: HashMap<String, MultipartState>,
|
||||
total_bytes: usize,
|
||||
@@ -390,6 +394,16 @@ impl FakeS3Target {
|
||||
lock(&self.backend.store).assign_own_version_ids = enabled;
|
||||
}
|
||||
|
||||
/// Mint own version ids for the multipart path only — models a target
|
||||
/// that adopts PutObject version ids but not CreateMultipartUpload ones.
|
||||
pub fn assign_own_multipart_version_ids(&self, enabled: bool) {
|
||||
lock(&self.backend.store).assign_own_multipart_version_ids = enabled;
|
||||
}
|
||||
|
||||
pub fn active_multipart_upload_count(&self) -> usize {
|
||||
lock(&self.backend.store).uploads.len()
|
||||
}
|
||||
|
||||
/// Queue `times` copies of a fault for one operation.
|
||||
pub fn inject(&self, operation: Operation, action: FaultAction, times: usize) {
|
||||
if times == 0 {
|
||||
@@ -659,6 +673,7 @@ fn parse_request(method: &Method, uri: &Uri) -> ParsedRequest {
|
||||
let operation = match (method, key.is_some()) {
|
||||
(&Method::HEAD, false) => Operation::HeadBucket,
|
||||
(&Method::GET, false) if query.contains_key("versioning") => Operation::GetBucketVersioning,
|
||||
(&Method::GET, false) if query.contains_key("versions") => Operation::ListObjectVersions,
|
||||
(&Method::PUT, true) if upload_id.is_some() && part_number.is_some() => Operation::UploadPart,
|
||||
(&Method::PUT, true) if upload_id.is_some() || query.contains_key("partNumber") => Operation::Unknown,
|
||||
(&Method::POST, true) if query.contains_key("uploads") => Operation::CreateMultipartUpload,
|
||||
@@ -810,7 +825,10 @@ async fn apply_non_body_fault(fault: Option<&RequestFault>, control: &Mutex<Cont
|
||||
update_consumed(control, fault.expect("matched fault").sequence, 0);
|
||||
Err(scripted_disconnect_error())
|
||||
}
|
||||
Some(FaultAction::SlowDrain { .. }) | Some(FaultAction::WrongEtag) | None => Ok(()),
|
||||
Some(FaultAction::SlowDrain { .. })
|
||||
| Some(FaultAction::WrongEtag)
|
||||
| Some(FaultAction::DisconnectAfterResponse)
|
||||
| None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,7 +869,7 @@ async fn collect_stream(
|
||||
Some(FaultAction::SlowDrain { chunk_bytes, delay }) => {
|
||||
return collect_stream_slow(body, capacity, *chunk_bytes, *delay).await;
|
||||
}
|
||||
Some(FaultAction::WrongEtag) | None => {}
|
||||
Some(FaultAction::WrongEtag) | Some(FaultAction::DisconnectAfterResponse) | None => {}
|
||||
}
|
||||
|
||||
let mut output = BytesMut::with_capacity(capacity);
|
||||
@@ -913,6 +931,9 @@ fn apply_response_fault<T>(mut response: S3Response<T>, fault: Option<&RequestFa
|
||||
if fault.is_some_and(|fault| fault.action == FaultAction::WrongEtag) {
|
||||
response.headers.insert(ETAG, HeaderValue::from_static(WRONG_ETAG));
|
||||
}
|
||||
if fault.is_some_and(|fault| fault.action == FaultAction::DisconnectAfterResponse) {
|
||||
response.headers.insert(DISCONNECT_HEADER, HeaderValue::from_static("true"));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
@@ -1101,6 +1122,63 @@ impl S3 for FakeBackend {
|
||||
))
|
||||
}
|
||||
|
||||
/// Prefix + max-keys subset only — enough for the replication-check probe
|
||||
/// key allocation. No pagination markers or delimiter folding.
|
||||
async fn list_object_versions(
|
||||
&self,
|
||||
req: S3Request<ListObjectVersionsInput>,
|
||||
) -> S3Result<S3Response<ListObjectVersionsOutput>> {
|
||||
let fault = request_fault(&req);
|
||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||
let state = lock(&self.store);
|
||||
let Some(bucket_state) = state.buckets.get(&req.input.bucket) else {
|
||||
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
|
||||
};
|
||||
let prefix = req.input.prefix.as_deref().unwrap_or_default();
|
||||
let max_keys = req.input.max_keys.unwrap_or(1000).max(0) as usize;
|
||||
|
||||
let mut keys: Vec<&String> = bucket_state.objects.keys().filter(|key| key.starts_with(prefix)).collect();
|
||||
keys.sort();
|
||||
|
||||
let mut versions = Vec::new();
|
||||
let mut delete_markers = Vec::new();
|
||||
'keys: for key in keys {
|
||||
for version in bucket_state.objects[key].iter().rev() {
|
||||
if versions.len() + delete_markers.len() >= max_keys {
|
||||
break 'keys;
|
||||
}
|
||||
if version.delete_marker {
|
||||
delete_markers.push(DeleteMarkerEntry {
|
||||
key: Some(key.clone()),
|
||||
version_id: Some(ObjectVersionId::from(version.version_id.clone())),
|
||||
last_modified: Some(version.last_modified.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
versions.push(s3s::dto::ObjectVersion {
|
||||
key: Some(key.clone()),
|
||||
version_id: Some(ObjectVersionId::from(version.version_id.clone())),
|
||||
last_modified: Some(version.last_modified.clone()),
|
||||
e_tag: Some(ETag::Strong(version.e_tag.clone())),
|
||||
size: Some(version.body.len() as i64),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
|
||||
Ok(apply_response_fault(
|
||||
S3Response::new(ListObjectVersionsOutput {
|
||||
name: Some(req.input.bucket),
|
||||
versions: Some(versions),
|
||||
delete_markers: Some(delete_markers),
|
||||
..Default::default()
|
||||
}),
|
||||
fault.as_ref(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn put_object(&self, req: S3Request<PutObjectInput>) -> S3Result<S3Response<PutObjectOutput>> {
|
||||
let fault = request_fault(&req);
|
||||
let _body_permit = timeout(MAX_FAULT_DURATION, Arc::clone(&self.body_limit).acquire_owned())
|
||||
@@ -1155,7 +1233,7 @@ impl S3 for FakeBackend {
|
||||
content_type: version.content_type,
|
||||
metadata: version.metadata,
|
||||
e_tag: Some(ETag::Strong(version.e_tag)),
|
||||
last_modified: Some(version.last_modified),
|
||||
last_modified: Some(version.last_modified.clone()),
|
||||
version_id: Some(version.version_id),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -1177,7 +1255,7 @@ impl S3 for FakeBackend {
|
||||
content_type: version.content_type,
|
||||
metadata: version.metadata,
|
||||
e_tag: Some(ETag::Strong(version.e_tag)),
|
||||
last_modified: Some(version.last_modified),
|
||||
last_modified: Some(version.last_modified.clone()),
|
||||
version_id: Some(version.version_id),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -1290,7 +1368,8 @@ impl S3 for FakeBackend {
|
||||
let upload_id = Uuid::new_v4().to_string();
|
||||
// Read the flag before the mutable borrow of `state.uploads` below
|
||||
// (and never re-lock the store: the mutex is not reentrant).
|
||||
let version_id = new_version_id(&headers, state.assign_own_version_ids)?;
|
||||
let mint_own = state.assign_own_version_ids || state.assign_own_multipart_version_ids;
|
||||
let version_id = new_version_id(&headers, mint_own)?;
|
||||
state.uploads.insert(
|
||||
upload_id.clone(),
|
||||
MultipartState {
|
||||
|
||||
@@ -2595,6 +2595,9 @@ async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<
|
||||
assert_eq!(payload["Targets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(payload["Targets"][0]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "OK");
|
||||
// A RustFS target adopts the source version id, so the P1-19
|
||||
// version-identity probe passes.
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["VersionFidelity"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["DeleteMarker"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["VersionDelete"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Cleanup"]["Status"], "OK");
|
||||
@@ -7489,6 +7492,334 @@ async fn test_scanner_never_cascades_inbound_replicas() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// P1-19 review follow-up: multipart fixes the target version at initiate
|
||||
/// and only reports it on completion, so a target can adopt PutObject
|
||||
/// version ids and still mint its own there — the check must not report OK
|
||||
/// while multipart deletes and heals would silently miss.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_flags_multipart_only_version_minting_target() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = "multipart-fidelity-dst";
|
||||
target.create_bucket(target_bucket);
|
||||
// PutObject mirrors the source version id; CreateMultipartUpload does not.
|
||||
target.assign_own_multipart_version_ids(true);
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut env_vars = replication_fast_env();
|
||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
|
||||
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?;
|
||||
|
||||
let source_bucket = "multipart-fidelity-src";
|
||||
let source_client = source_env.create_s3_client();
|
||||
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||
|
||||
let target_arn = set_replication_target_with_options(
|
||||
&source_env,
|
||||
source_bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||
|
||||
let response = run_replication_check(&source_env, source_bucket).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await?;
|
||||
|
||||
assert_eq!(payload["Status"], "FAILED", "multipart drift must fail the check: {payload}");
|
||||
let target_report = &payload["Targets"][0];
|
||||
let fidelity = &target_report["Phases"]["VersionFidelity"];
|
||||
assert_eq!(fidelity["Status"], "FAILED", "{payload}");
|
||||
assert_eq!(fidelity["Code"], "BucketRemoteTargetVersionMismatch", "{payload}");
|
||||
assert!(
|
||||
fidelity["Error"]
|
||||
.as_str()
|
||||
.is_some_and(|error| error.contains("CreateMultipartUpload")),
|
||||
"the failure must name the multipart path: {payload}"
|
||||
);
|
||||
// The PutObject leg mirrored, so it is the multipart probe that failed.
|
||||
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "SKIPPED", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
||||
|
||||
let probe_key = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.operation == FakeTargetOperation::PutObject)
|
||||
.and_then(|record| record.key)
|
||||
.ok_or("the probe PUT never reached the fake target")?;
|
||||
assert!(
|
||||
target.stored_versions(target_bucket, &probe_key).is_empty(),
|
||||
"both probe versions must be cleaned up on the mismatching target"
|
||||
);
|
||||
|
||||
target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_aborts_failed_multipart_probes() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = "multipart-cleanup-dst";
|
||||
target.create_bucket(target_bucket);
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut env_vars = replication_fast_env();
|
||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
|
||||
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?;
|
||||
|
||||
let source_bucket = "multipart-cleanup-src";
|
||||
let source_client = source_env.create_s3_client();
|
||||
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||
|
||||
let target_arn = set_replication_target_with_options(
|
||||
&source_env,
|
||||
source_bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||
|
||||
for failed_operation in [FakeTargetOperation::UploadPart, FakeTargetOperation::CompleteMultipartUpload] {
|
||||
target.clear_faults();
|
||||
target.take_requests();
|
||||
target.inject(failed_operation, FakeTargetFault::Status(StatusCode::SERVICE_UNAVAILABLE), 16);
|
||||
|
||||
let response = run_replication_check(&source_env, source_bucket).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await?;
|
||||
assert_eq!(
|
||||
payload["Status"], "FAILED",
|
||||
"the injected multipart failure must fail the check: {payload}"
|
||||
);
|
||||
|
||||
let requests = target.requests();
|
||||
assert!(
|
||||
requests.iter().any(|request| {
|
||||
request.operation == failed_operation
|
||||
&& request.fault == Some(FakeTargetFault::Status(StatusCode::SERVICE_UNAVAILABLE))
|
||||
}),
|
||||
"the check must reach the injected {failed_operation:?} failure: {requests:?}"
|
||||
);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.any(|request| request.operation == FakeTargetOperation::AbortMultipartUpload),
|
||||
"the failed {failed_operation:?} probe must be aborted: {requests:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
target.active_multipart_upload_count(),
|
||||
0,
|
||||
"the failed {failed_operation:?} probe must not leave multipart state"
|
||||
);
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
||||
}
|
||||
|
||||
target.clear_faults();
|
||||
target.take_requests();
|
||||
target.inject(FakeTargetOperation::CompleteMultipartUpload, FakeTargetFault::DisconnectAfterResponse, 16);
|
||||
|
||||
let response = run_replication_check(&source_env, source_bucket).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await?;
|
||||
let target_report = &payload["Targets"][0];
|
||||
assert_eq!(target_report["Status"], "FAILED", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["VersionFidelity"]["Status"], "FAILED", "{payload}");
|
||||
assert_eq!(
|
||||
target_report["Phases"]["Cleanup"]["Status"], "OK",
|
||||
"NoSuchUpload after an ambiguous complete means the multipart artifact is gone: {payload}"
|
||||
);
|
||||
let requests = target.requests();
|
||||
let completed_key = requests
|
||||
.iter()
|
||||
.find(|request| {
|
||||
request.operation == FakeTargetOperation::CompleteMultipartUpload
|
||||
&& request.fault == Some(FakeTargetFault::DisconnectAfterResponse)
|
||||
})
|
||||
.and_then(|request| request.key.as_deref())
|
||||
.expect("the scripted complete response disconnect must be observed");
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.any(|request| request.operation == FakeTargetOperation::AbortMultipartUpload),
|
||||
"the ambiguous complete must still attempt abort: {requests:?}"
|
||||
);
|
||||
assert_eq!(target.active_multipart_upload_count(), 0);
|
||||
assert!(
|
||||
target.stored_versions(target_bucket, completed_key).is_empty(),
|
||||
"outer cleanup must remove the object committed before the response disconnect"
|
||||
);
|
||||
|
||||
target.clear_faults();
|
||||
target.take_requests();
|
||||
target.inject(FakeTargetOperation::UploadPart, FakeTargetFault::Status(StatusCode::FORBIDDEN), 16);
|
||||
target.inject(
|
||||
FakeTargetOperation::AbortMultipartUpload,
|
||||
FakeTargetFault::Status(StatusCode::SERVICE_UNAVAILABLE),
|
||||
16,
|
||||
);
|
||||
|
||||
let response = run_replication_check(&source_env, source_bucket).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await?;
|
||||
let target_report = &payload["Targets"][0];
|
||||
assert_eq!(target_report["Status"], "FAILED", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["VersionFidelity"]["Status"], "FAILED", "{payload}");
|
||||
assert_eq!(
|
||||
target_report["Phases"]["Cleanup"]["Status"], "FAILED",
|
||||
"an unremoved multipart probe must be reported as a cleanup failure: {payload}"
|
||||
);
|
||||
assert_eq!(
|
||||
target_report["Error"], "s3:ReplicateObject permissions missing for replication user",
|
||||
"the primary multipart error must remain the target error: {payload}"
|
||||
);
|
||||
assert_eq!(
|
||||
target_report["Phases"]["VersionFidelity"]["Error"], "s3:ReplicateObject permissions missing for replication user",
|
||||
"{payload}"
|
||||
);
|
||||
assert_eq!(
|
||||
target_report["Phases"]["Cleanup"]["Error"], "failed to abort multipart replication probe",
|
||||
"{payload}"
|
||||
);
|
||||
assert!(
|
||||
target.requests().iter().any(|request| {
|
||||
request.operation == FakeTargetOperation::AbortMultipartUpload
|
||||
&& request.fault == Some(FakeTargetFault::Status(StatusCode::SERVICE_UNAVAILABLE))
|
||||
}),
|
||||
"the abort failure must be observed"
|
||||
);
|
||||
assert_eq!(
|
||||
target.active_multipart_upload_count(),
|
||||
1,
|
||||
"the report must match the retained multipart state"
|
||||
);
|
||||
|
||||
target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// P1-19 (backlog#1675): the supported replication contract is targets that
|
||||
/// adopt the source version id (RustFS/MinIO semantics). A target that mints
|
||||
/// its own version ids silently breaks every version-addressed operation that
|
||||
/// follows — version deletes and heal re-drives never match, diverging the
|
||||
/// two sides. replication-check must surface this explicitly: a
|
||||
/// VersionFidelity phase that compares the probe PUT's response version id
|
||||
/// against the sent source version id and fails with
|
||||
/// BucketRemoteTargetVersionMismatch — while still cleaning up the probe
|
||||
/// object via the version id the target actually assigned.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_flags_version_minting_target() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = "version-fidelity-dst";
|
||||
target.create_bucket(target_bucket);
|
||||
target.assign_own_version_ids(true);
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut env_vars = replication_fast_env();
|
||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
|
||||
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?;
|
||||
|
||||
let source_bucket = "version-fidelity-src";
|
||||
let source_client = source_env.create_s3_client();
|
||||
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||
|
||||
let target_arn = set_replication_target_with_options(
|
||||
&source_env,
|
||||
source_bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||
|
||||
let response = run_replication_check(&source_env, source_bucket).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await?;
|
||||
|
||||
assert_eq!(
|
||||
payload["Status"], "FAILED",
|
||||
"a version-minting target must fail the replication check: {payload}"
|
||||
);
|
||||
let target_report = &payload["Targets"][0];
|
||||
assert_eq!(target_report["Status"], "FAILED", "target must be FAILED: {payload}");
|
||||
let fidelity = &target_report["Phases"]["VersionFidelity"];
|
||||
assert_eq!(fidelity["Status"], "FAILED", "VersionFidelity phase must fail: {payload}");
|
||||
assert_eq!(
|
||||
fidelity["Code"], "BucketRemoteTargetVersionMismatch",
|
||||
"the failure must carry a machine-readable code: {payload}"
|
||||
);
|
||||
// The probe PUT itself succeeded (fidelity is judged from its response);
|
||||
// the later mutation phases are pointless against a drifting target and
|
||||
// must be skipped, but cleanup still runs.
|
||||
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "SKIPPED", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
||||
|
||||
// The probe PUT must carry the source version as `?versionId=` — the
|
||||
// exact shape live replication uses (P0-5), and the only shape MinIO
|
||||
// consumes. The journal records the query value.
|
||||
let probe_put = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.operation == FakeTargetOperation::PutObject)
|
||||
.ok_or("the probe PUT never reached the fake target")?;
|
||||
let probe_query_version = probe_put
|
||||
.version_id
|
||||
.as_deref()
|
||||
.ok_or("the probe PUT must carry a versionId query")?;
|
||||
assert!(
|
||||
uuid::Uuid::parse_str(probe_query_version).is_ok(),
|
||||
"the probe versionId query must be the source uuid, got {probe_query_version}"
|
||||
);
|
||||
|
||||
// No probe residue: cleanup must address the version id the target
|
||||
// actually assigned, not the source id (which never matched anything).
|
||||
let probe_key = probe_put.key.ok_or("probe PUT journal record has no key")?;
|
||||
assert!(
|
||||
target.stored_versions(target_bucket, &probe_key).is_empty(),
|
||||
"the probe object must be cleaned up on the mismatching target"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- P1-21 (backlog#1675): delayed delete-marker purge failure handling ---
|
||||
//
|
||||
// The fixtures below wire a versioned source bucket to a FakeS3Target with the
|
||||
|
||||
@@ -32,7 +32,7 @@ pub mod bucket {
|
||||
pub mod bucket_target_sys {
|
||||
pub use crate::bucket::bucket_target_sys::{
|
||||
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
|
||||
TargetClient,
|
||||
TargetClient, append_version_id_query,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1450,7 +1450,7 @@ fn resolve_put_api_version_id(source_version_id: &str) -> Option<&str> {
|
||||
/// member, so the query is spliced in via `map_request`, which runs at
|
||||
/// `modify_before_signing`: the parameter becomes part of the SigV4 canonical
|
||||
/// request.
|
||||
fn append_version_id_query(uri: &str, version_id: &str) -> String {
|
||||
pub fn append_version_id_query(uri: &str, version_id: &str) -> String {
|
||||
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||
format!("{uri}{separator}versionId={}", urlencoding::encode(version_id))
|
||||
}
|
||||
@@ -1861,6 +1861,9 @@ impl TargetClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// On success returns the version id the target assigned (from
|
||||
/// `x-amz-version-id`), letting callers audit the version-identity
|
||||
/// contract — a target that adopts the source version echoes it back.
|
||||
pub async fn put_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -1868,7 +1871,7 @@ impl TargetClient {
|
||||
size: i64,
|
||||
body: ByteStream,
|
||||
opts: &PutObjectOptions,
|
||||
) -> Result<(), S3ClientError> {
|
||||
) -> Result<Option<String>, S3ClientError> {
|
||||
let mut headers = opts.header();
|
||||
|
||||
let builder = self.client.put_object();
|
||||
@@ -1903,7 +1906,7 @@ impl TargetClient {
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Ok(output) => Ok(output.version_id().map(ToOwned::to_owned)),
|
||||
Err(e) => match e {
|
||||
SdkError::ServiceError(service_err) => {
|
||||
let err = service_err.into_err();
|
||||
|
||||
@@ -74,10 +74,10 @@ use rustfs_utils::http::{
|
||||
use rustfs_utils::{DEFAULT_SIP_HASH_KEY, get_env_usize, sip_hash};
|
||||
#[cfg(test)]
|
||||
use s3s::dto::ReplicationConfiguration;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Display;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use tokio::io::AsyncRead;
|
||||
@@ -105,6 +105,7 @@ const EVENT_RESYNC_RUNTIME_CHANNEL_FAILED: &str = "replication_resync_runtime_ch
|
||||
const EVENT_DELETE_MARKER_PURGE_FAILED: &str = "replication_delete_marker_purge_failed";
|
||||
const EVENT_DELETE_MARKER_PURGE_MRF: &str = "replication_delete_marker_purge_mrf";
|
||||
const METRIC_DELETE_MARKER_PURGE_TOTAL: &str = "rustfs_replication_delete_marker_purge_total";
|
||||
const EVENT_REPLICATION_VERSION_IDENTITY_DRIFT: &str = "replication_version_identity_drift";
|
||||
const REPLICATION_TARGET_OFFLINE_ERROR_MARKERS: &[&str] = &[
|
||||
"dispatch failure",
|
||||
"timeouterror",
|
||||
@@ -186,6 +187,57 @@ fn is_head_proxy_failure(err: &SdkError<HeadObjectError>) -> bool {
|
||||
should_count_head_proxy_failure(is_not_found, code, raw_status)
|
||||
}
|
||||
|
||||
const METRIC_VERSION_IDENTITY_DRIFT_TOTAL: &str = "rustfs_replication_version_identity_drift_total";
|
||||
|
||||
/// Targets that already produced a version-identity-drift warning this
|
||||
/// process lifetime, by ARN. Deduping is advisory only (the metric still
|
||||
/// counts every drifting PUT), so a reconfigured target re-warning only
|
||||
/// after a restart is acceptable.
|
||||
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashSet<String>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
|
||||
|
||||
/// Runtime half of the P1-19 version-identity contract (the explicit probe
|
||||
/// lives in replication-check's VersionFidelity phase): every replication PUT
|
||||
/// response reveals whether the target adopted the source version id. A
|
||||
/// target minting its own ids silently breaks version-addressed deletes and
|
||||
/// heal, so surface it — once per target — instead of letting the divergence
|
||||
/// accumulate unseen.
|
||||
/// Pure drift judgment: the contract only applies when the source addressed a
|
||||
/// real (non-nil) version uuid, and drift means the target answered with
|
||||
/// anything else — including nothing at all.
|
||||
fn version_identity_drifted(source_version_id: &str, assigned_version_id: Option<&str>) -> bool {
|
||||
if source_version_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// A nil source uuid travels as the literal "null" (unversioned-source
|
||||
// semantics); no identity contract applies to it.
|
||||
if Uuid::parse_str(source_version_id).map(|uuid| uuid.is_nil()).unwrap_or(true) {
|
||||
return false;
|
||||
}
|
||||
assigned_version_id != Some(source_version_id)
|
||||
}
|
||||
|
||||
fn audit_target_version_identity(tgt_client: &TargetClient, source_version_id: &str, assigned_version_id: Option<&str>) {
|
||||
if !version_identity_drifted(source_version_id, assigned_version_id) {
|
||||
return;
|
||||
}
|
||||
counter!(METRIC_VERSION_IDENTITY_DRIFT_TOTAL).increment(1);
|
||||
let mut warned = VERSION_IDENTITY_WARNED_ARNS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if warned.insert(tgt_client.arn.clone()) {
|
||||
warn!(
|
||||
event = EVENT_REPLICATION_VERSION_IDENTITY_DRIFT,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
arn = %tgt_client.arn,
|
||||
endpoint = %tgt_client.endpoint,
|
||||
sent_version_id = %source_version_id,
|
||||
assigned_version_id = assigned_version_id.unwrap_or("<none>"),
|
||||
"Replication target does not adopt source version ids; version-addressed replication cannot converge (run ?replication-check for details)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_proxy_request(bucket: &str, api: &str, is_err: bool) {
|
||||
if let Some(stats) = runtime_sources::replication_stats() {
|
||||
stats.inc_proxy(bucket, api, is_err).await;
|
||||
@@ -2872,6 +2924,13 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
let result = tgt_client
|
||||
.put_object(&tgt_client.bucket, &object, transfer_size, byte_stream, &put_opts)
|
||||
.await
|
||||
.map(|assigned_version_id| {
|
||||
audit_target_version_identity(
|
||||
&tgt_client,
|
||||
&put_opts.internal.source_version_id,
|
||||
assigned_version_id.as_deref(),
|
||||
)
|
||||
})
|
||||
.map_err(|e| std::io::Error::other(e.to_string()));
|
||||
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
|
||||
if has_tagging_replication {
|
||||
@@ -3279,6 +3338,13 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
let result = tgt_client
|
||||
.put_object(&tgt_client.bucket, &object, transfer_size, byte_stream, &put_opts)
|
||||
.await
|
||||
.map(|assigned_version_id| {
|
||||
audit_target_version_identity(
|
||||
&tgt_client,
|
||||
&put_opts.internal.source_version_id,
|
||||
assigned_version_id.as_deref(),
|
||||
)
|
||||
})
|
||||
.map_err(|e| std::io::Error::other(e.to_string()));
|
||||
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
|
||||
if has_tagging_replication {
|
||||
@@ -3470,15 +3536,27 @@ async fn replicate_object_with_multipart<S: ReplicationObjectIO>(ctx: MultipartR
|
||||
|
||||
let actual_size = replication_multipart_complete_actual_size(&object_info.user_defined);
|
||||
|
||||
cli.complete_multipart_upload(
|
||||
dst_bucket,
|
||||
object,
|
||||
&upload_id,
|
||||
uploaded_parts,
|
||||
&replication_complete_multipart_options(actual_size, object_info.etag.clone().unwrap_or_default(), object_info.mod_time),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let completed = cli
|
||||
.complete_multipart_upload(
|
||||
dst_bucket,
|
||||
object,
|
||||
&upload_id,
|
||||
uploaded_parts,
|
||||
&replication_complete_multipart_options(
|
||||
actual_size,
|
||||
object_info.etag.clone().unwrap_or_default(),
|
||||
object_info.mod_time,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
|
||||
// Multipart decides the target version at initiate time and only reveals
|
||||
// it on completion, so this is where the identity contract is observable
|
||||
// for this path. A target can mirror PutObject version ids and still mint
|
||||
// its own here, which would leave multipart deletes and heals addressing
|
||||
// a version that never existed.
|
||||
audit_target_version_identity(&cli, &put_opts.internal.source_version_id, completed.version_id());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3525,6 +3603,27 @@ mod tests {
|
||||
ReplicationTargetStore::register_test_target(target).await;
|
||||
}
|
||||
|
||||
/// P1-19 runtime spot-check exemption matrix: drift only applies when the
|
||||
/// source addressed a real version uuid.
|
||||
#[test]
|
||||
fn test_version_identity_drift_judgment() {
|
||||
let source = "6fa459ea-ee8a-3ca4-894e-db77e160355e";
|
||||
for (sent, got, expected) in [
|
||||
(source, Some(source), false),
|
||||
(source, Some("0e304ce5-33e9-4b8a-9b12-9e40a53e6ded"), true),
|
||||
(source, None, true),
|
||||
("", None, false),
|
||||
("null", Some("anything"), false),
|
||||
("00000000-0000-0000-0000-000000000000", Some("anything"), false),
|
||||
] {
|
||||
assert_eq!(
|
||||
version_identity_drifted(sent, got),
|
||||
expected,
|
||||
"sent {sent:?} got {got:?} must judge drift = {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resync_admission_configuration_is_bounded() {
|
||||
assert_eq!(ENV_REPL_RESYNC_MAX_JOBS, "RUSTFS_REPL_RESYNC_MAX_JOBS");
|
||||
|
||||
Reference in New Issue
Block a user