mirror of
https://github.com/rustfs/rustfs.git
synced 2026-11-05 18:36:34 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3c8825d80 |
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Run CLA Bot
|
||||
if: github.event_name != 'merge_group'
|
||||
uses: overtrue/cla-bot@f054da99d5777e47e1af9933737b9c0c5584be8d # v0.1.0
|
||||
uses: overtrue/cla-bot@5e5929846a60cfb42b16d0d38e135ef67d94fc88 # v0.0.9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
registry-token: ${{ steps.registry-token.outputs.token }}
|
||||
|
||||
@@ -107,32 +107,6 @@ jobs:
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture
|
||||
|
||||
# Native multipart completion publishes its payload from a different
|
||||
# parent than its metadata, and abort renames under a precondition; both
|
||||
# need their own Windows handle discipline.
|
||||
- name: Test native multipart complete and abort publication
|
||||
shell: pwsh
|
||||
run: >-
|
||||
cargo test -p rustfs-ecstore --lib --
|
||||
native_abort_missing_state_with_actual_owned_records_never_becomes_legacy_or_missing
|
||||
native_abort_pending_intent_is_not_a_delete_certificate_and_raw_cleanup_preserves_exact_evidence
|
||||
--nocapture
|
||||
|
||||
# Owned-undo reclaim removes a directory it pinned, copy heal persists
|
||||
# fault bytes through a handle, and metadata reads meet a directory.
|
||||
- name: Test native multipart reclaim, copy heal, and directory reads
|
||||
shell: pwsh
|
||||
run: >-
|
||||
cargo test -p rustfs-ecstore --lib --
|
||||
native_multipart_owned_undo
|
||||
native_multipart_copy_heal
|
||||
orphan_multipart_cleanup_rejects_metadata_directory_on_one_disk
|
||||
--nocapture
|
||||
|
||||
- name: Test directory removal fallback
|
||||
shell: pwsh
|
||||
run: "cargo test -p rustfs-ecstore --lib disk::fs::tests::test_remove -- --nocapture"
|
||||
|
||||
- name: Test Windows path conversion
|
||||
shell: pwsh
|
||||
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
|
||||
|
||||
Generated
-3
@@ -10277,7 +10277,6 @@ dependencies = [
|
||||
"hotpath",
|
||||
"parking_lot 0.13.0",
|
||||
"rand 0.10.3",
|
||||
"rustfs-common",
|
||||
"rustfs-io-metrics",
|
||||
"rustfs-utils",
|
||||
"serde",
|
||||
@@ -10790,10 +10789,8 @@ version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"csv-core",
|
||||
"datafusion",
|
||||
"derive_builder",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"parking_lot 0.13.0",
|
||||
|
||||
@@ -191,7 +191,6 @@ quinn = { version = "0.11.12", default-features = false, features = [
|
||||
# Serialization and Data Formats
|
||||
apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
|
||||
bytes = { version = "1.12.1" }
|
||||
csv-core = "0.1.13"
|
||||
bytesize = "2.7.0"
|
||||
byteorder = "1.5.0"
|
||||
flatbuffers = "25.12.19"
|
||||
|
||||
@@ -175,17 +175,12 @@ pub const ENV_HTTP_SOCKET_RECV_BUFFER_BYTES: &str = "RUSTFS_HTTP_SOCKET_RECV_BUF
|
||||
pub const DEFAULT_HTTP_SOCKET_RECV_BUFFER_BYTES: usize = 0;
|
||||
|
||||
/// Environment variable for the S3 request-body inter-chunk read timeout
|
||||
/// (seconds). Default: 300. Set to 0 to disable the inactivity timeout.
|
||||
/// (seconds). Default: 300. Set to 0 to disable.
|
||||
///
|
||||
/// This bounds how long a `PutObject`/upload may wait for the *next* body chunk
|
||||
/// while more bytes are still expected. It resets on every chunk, so it does not
|
||||
/// penalize slow-but-progressing uploads — it only fires when a peer sends a
|
||||
/// partial body and then goes silent *without* closing the connection (no EOF).
|
||||
/// Independently of this knob, `PutObject` and `UploadPart` require at least
|
||||
/// 64 KiB of body progress per 60 seconds of server wait (the minimum-progress
|
||||
/// floor, logged as `upload_body_progress_stalled`); it bounds trickle uploads
|
||||
/// that would otherwise pin upload admission indefinitely, and it stays in
|
||||
/// force when this timeout is set to 0.
|
||||
/// A reverse proxy or CDN that forwards a truncated body this way would
|
||||
/// otherwise make RustFS wait forever for bytes that never arrive; with this
|
||||
/// timeout the stalled read is aborted and logged with the received/expected
|
||||
|
||||
@@ -94,12 +94,3 @@ pub(crate) mod scanner_peer_bucket {
|
||||
pub(crate) use ecstore_erasure::{ReedSolomonEncoder, calc_shard_size};
|
||||
pub(crate) use ecstore_rpc::PeerRestClient;
|
||||
}
|
||||
|
||||
/// Storage handles used to seed a pre-validation bucket record while the fixture server is stopped.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod bucket_tag_fixture {
|
||||
pub(crate) use rustfs_ecstore::api::bucket::metadata::load_bucket_metadata;
|
||||
pub(crate) use rustfs_ecstore::api::layout::{DisksLayout, EndpointServerPools};
|
||||
pub(crate) use rustfs_ecstore::api::runtime::InstanceContext;
|
||||
pub(crate) use rustfs_ecstore::api::storage::{ECStore, init_local_disks_with_instance_ctx};
|
||||
}
|
||||
|
||||
@@ -360,26 +360,12 @@ impl OpaMock {
|
||||
|
||||
pub(crate) async fn expect_bucket_tags(&mut self, action: &str, bucket: &str, department: Option<&str>) -> TestResult {
|
||||
let payload = self.next_request().await?;
|
||||
Self::assert_bucket_tags(&payload, action, bucket, department);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expect_versioned_tag_permission(&mut self, bucket: &str, object: &str, version: &str) -> TestResult {
|
||||
let payload = self.next_request().await?;
|
||||
Self::assert_bucket_tags(&payload, "s3:GetObjectVersionTagging", bucket, Some("finance"));
|
||||
assert_eq!(payload["input"]["resource"]["object"], object);
|
||||
assert_eq!(payload["input"]["context"]["conditions"]["versionid"], serde_json::json!([version]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_bucket_tags(payload: &Value, action: &str, bucket: &str, department: Option<&str>) {
|
||||
let input = &payload["input"];
|
||||
assert_eq!(input["identity"]["account"], "opabuckettags");
|
||||
assert_eq!(input["identity"]["is_owner"], false);
|
||||
assert_eq!(input["action"], action);
|
||||
assert_eq!(input["resource"]["bucket"], bucket);
|
||||
let expected = department.map(|value| serde_json::json!([value]));
|
||||
assert_eq!(input["context"]["conditions"].get("ExistingBucketTag/department"), expected.as_ref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_validation(&mut self) -> TestResult {
|
||||
@@ -692,61 +678,22 @@ async fn test_list_buckets_opa_contract() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop and reap the directory's owning server before opening the fixture store.
|
||||
async fn seed_legacy_duplicate_bucket_tags(env: &mut RustFSTestEnvironment, bucket: &str) -> TestResult {
|
||||
use crate::storage_api::bucket_tag_fixture::{
|
||||
DisksLayout, ECStore, EndpointServerPools, InstanceContext, init_local_disks_with_instance_ctx, load_bucket_metadata,
|
||||
};
|
||||
|
||||
let server = env
|
||||
.process
|
||||
.as_mut()
|
||||
.ok_or("missing server process before offline fixture write")?;
|
||||
server.kill()?;
|
||||
server.wait()?;
|
||||
env.process = None;
|
||||
let layout = DisksLayout::from_volumes(std::slice::from_ref(&env.temp_dir))?;
|
||||
let (pools, _) = EndpointServerPools::create_server_endpoints(&env.address, &layout).await?;
|
||||
let context = std::sync::Arc::new(InstanceContext::new());
|
||||
init_local_disks_with_instance_ctx(&context, pools.clone()).await?;
|
||||
let store = ECStore::new_with_instance_ctx(env.address.parse()?, pools, Default::default(), context.clone()).await?;
|
||||
let _cancel = context
|
||||
.background_cancel_token()
|
||||
.expect("store binds its shutdown token")
|
||||
.drop_guard();
|
||||
let mut metadata = load_bucket_metadata(store.clone(), bucket).await?;
|
||||
let incarnation = metadata.bucket_incarnation_id;
|
||||
let policy = metadata.policy_config_json.clone();
|
||||
assert!(!incarnation.is_nil());
|
||||
assert!(!policy.is_empty(), "the bucket-policy Allow must survive legacy seeding");
|
||||
// Encode the duplicate tag set with the same bucket record and erasure writer used by the store.
|
||||
let xml = b"<Tagging><TagSet><Tag><Key>department</Key><Value>finance</Value></Tag><Tag><Key>department</Key><Value>engineering</Value></Tag></TagSet></Tagging>";
|
||||
metadata.tagging_config_xml = xml.to_vec();
|
||||
metadata.save_with_store(store.clone()).await?;
|
||||
let persisted = load_bucket_metadata(store, bucket).await?;
|
||||
assert_eq!(persisted.bucket_incarnation_id, incarnation);
|
||||
assert_eq!(persisted.policy_config_json, policy);
|
||||
assert_eq!(persisted.tagging_config_xml.as_slice(), xml);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Tag, Tagging, TaggingDirective, VersioningConfiguration,
|
||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Tag, Tagging, VersioningConfiguration,
|
||||
};
|
||||
|
||||
init_logging();
|
||||
let mut opa = OpaMock::start().await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let opa_url = opa.url.clone();
|
||||
let server_env = [
|
||||
("RUSTFS_POLICY_PLUGIN_URL", opa_url.as_str()),
|
||||
env.start_rustfs_server_without_cleanup_with_env(&[
|
||||
("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()),
|
||||
("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN),
|
||||
("NO_PROXY", "127.0.0.1,localhost"),
|
||||
];
|
||||
env.start_rustfs_server_without_cleanup_with_env(&server_env).await?;
|
||||
])
|
||||
.await?;
|
||||
let secret = uuid::Uuid::new_v4().to_string();
|
||||
create_user(&env, "opabuckettags", &secret).await?;
|
||||
let client = aws_sdk_s3::Client::from_conf(build_test_s3_config(&env.url, "opabuckettags", &secret, None, "e2e-bucket-tags"));
|
||||
@@ -786,7 +733,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
.await?;
|
||||
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
let head = client
|
||||
client
|
||||
.head_object()
|
||||
.bucket(source)
|
||||
.key("report")
|
||||
@@ -794,8 +741,6 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
assert_eq!(head.tag_count(), Some(1));
|
||||
opa.expect_versioned_tag_permission(source, "report", version).await?;
|
||||
client.list_objects_v2().bucket(source).send().await?;
|
||||
opa.expect_bucket_tags("s3:ListBucket", source, Some("finance")).await?;
|
||||
client.list_object_versions().bucket(source).send().await?;
|
||||
@@ -805,65 +750,28 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
admin.create_bucket().bucket(destination).send().await?;
|
||||
set_department(&admin, destination, "engineering").await?;
|
||||
let copy_source = format!("{source}/report?versionId={version}");
|
||||
for directive in [TaggingDirective::Copy, TaggingDirective::Replace] {
|
||||
let error = client
|
||||
.copy_object()
|
||||
.bucket(destination)
|
||||
.key("copied")
|
||||
.copy_source(©_source)
|
||||
.set_tagging_directive((directive == TaggingDirective::Replace).then_some(TaggingDirective::Replace))
|
||||
.set_tagging((directive == TaggingDirective::Replace).then(|| "department=legal".to_owned()))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("source tags must not authorize a different destination");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
if directive == TaggingDirective::Copy {
|
||||
opa.expect_versioned_tag_permission(source, "report", version).await?;
|
||||
}
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("engineering"))
|
||||
.await?;
|
||||
let missing = admin
|
||||
.head_object()
|
||||
.bucket(destination)
|
||||
.key("copied")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("denied COPY and REPLACE must leave no destination object");
|
||||
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
}
|
||||
let error = client
|
||||
.copy_object()
|
||||
.bucket(destination)
|
||||
.key("copied")
|
||||
.copy_source(©_source)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("source tags must not authorize a different destination");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("engineering"))
|
||||
.await?;
|
||||
set_department(&admin, destination, "finance").await?;
|
||||
for directive in [TaggingDirective::Copy, TaggingDirective::Replace] {
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(destination)
|
||||
.key("copied")
|
||||
.copy_source(©_source)
|
||||
.set_tagging_directive((directive == TaggingDirective::Replace).then_some(TaggingDirective::Replace))
|
||||
.set_tagging((directive == TaggingDirective::Replace).then(|| "department=legal".to_owned()))
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
// The access hook checks preliminary tags, then the copy use case checks
|
||||
// the exact source reader's tags before publishing the destination.
|
||||
for _ in 0..2 {
|
||||
if directive == TaggingDirective::Copy {
|
||||
opa.expect_versioned_tag_permission(source, "report", version).await?;
|
||||
}
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
|
||||
}
|
||||
let tags = admin.get_object_tagging().bucket(destination).key("copied").send().await?;
|
||||
assert_eq!(tags.tag_set().len(), 1);
|
||||
assert_eq!(tags.tag_set()[0].key(), "department");
|
||||
assert_eq!(
|
||||
tags.tag_set()[0].value(),
|
||||
if directive == TaggingDirective::Copy {
|
||||
"engineering"
|
||||
} else {
|
||||
"legal"
|
||||
}
|
||||
);
|
||||
}
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(destination)
|
||||
.key("copied")
|
||||
.copy_source(©_source)
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
|
||||
|
||||
let upload = client
|
||||
.create_multipart_upload()
|
||||
@@ -939,14 +847,6 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("engineering"))
|
||||
.await?;
|
||||
let missing = admin
|
||||
.head_object()
|
||||
.bucket(destination)
|
||||
.key("denied-source")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a denied source must leave no destination object");
|
||||
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
set_department(&client, source, "finance")
|
||||
.await
|
||||
.expect_err("requested tags must not authorize their own mutation");
|
||||
@@ -985,7 +885,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
|
||||
opa.expect_bucket_tags("s3:GetObject", destination, Some("engineering"))
|
||||
.await?;
|
||||
let invalid_write = admin
|
||||
admin
|
||||
.put_bucket_tagging()
|
||||
.bucket(destination)
|
||||
.tagging(
|
||||
@@ -995,25 +895,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("new duplicate tags must be rejected before publication");
|
||||
assert_eq!(invalid_write.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidTag"));
|
||||
assert_eq!(invalid_write.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
|
||||
set_department(&admin, source, "finance").await?;
|
||||
seed_legacy_duplicate_bucket_tags(&mut env, destination).await?;
|
||||
env.start_rustfs_server_without_cleanup_with_env(&server_env).await?;
|
||||
// Prove this process can authorize the same caller and read a healthy bucket's persisted tags.
|
||||
let read = client
|
||||
.get_object()
|
||||
.bucket(source)
|
||||
.key("report")
|
||||
.version_id(version)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(destination)
|
||||
|
||||
@@ -192,7 +192,6 @@ pub mod bucket {
|
||||
table_catalog_path_hash, unreadable_config_refusal,
|
||||
};
|
||||
pub use crate::bucket::metadata::{BUCKET_DURABILITY_CONFIG, BUCKET_ON_DEMAND_MIGRATION_CONFIG};
|
||||
pub use crate::bucket::metadata::{MAX_BUCKET_TAGS, validate_tag_set_entries};
|
||||
}
|
||||
|
||||
pub mod config_parse_mode {
|
||||
@@ -332,8 +331,8 @@ pub mod bucket {
|
||||
pub mod utils {
|
||||
pub use crate::bucket::utils::{
|
||||
check_bucket_and_object_names, check_list_objs_args, check_object_name_for_length_and_slash,
|
||||
check_valid_bucket_name_strict, deserialize, has_bad_path_component, has_xml_invalid_control_characters,
|
||||
is_meta_bucketname, is_valid_object_prefix, serialize,
|
||||
check_valid_bucket_name_strict, deserialize, has_bad_path_component, is_meta_bucketname, is_valid_object_prefix,
|
||||
serialize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -452,15 +451,9 @@ pub mod disk {
|
||||
pub use crate::cluster::rpc::background_reader_stream::BackgroundReaderStream;
|
||||
pub use crate::disk::background_memory::BackgroundEncodeBuffer;
|
||||
pub use crate::disk::disk_store::get_object_disk_read_timeout;
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::disk::disk_store::wait_for_multipart_index_bootstrap;
|
||||
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::disk::local::{local_durability_modes, set_local_rename_data_fail_after_metadata_commit};
|
||||
pub use crate::disk::multipart_index::{
|
||||
MULTIPART_INDEX_RPC_MAX_BYTES, MULTIPART_INDEX_RPC_MAX_REQUEST_BYTES, MultipartIndexPage, MultipartIndexRpcReply,
|
||||
MultipartIndexRpcRequest,
|
||||
};
|
||||
pub use crate::disk::multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
|
||||
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse,
|
||||
@@ -473,15 +466,13 @@ pub mod disk {
|
||||
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
||||
pub use crate::disk::{
|
||||
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
||||
BoundedDeleteRecoveryDiscovery, BoundedDeleteRecoveryInspection, BoundedDeleteRecoverySettlement,
|
||||
BoundedDeleteRecoverySettlementReply, CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration,
|
||||
CurrentDeleteTransition, DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES, DELETE_RECOVERY_MAX_INSPECTION_ENTRIES,
|
||||
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
|
||||
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
|
||||
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
|
||||
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
|
||||
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEAL_REPLACEMENT_RECOVERY_DIR, HEALING_MARKER_PATH,
|
||||
NsScannerOpenRequest, OldCurrentSize, POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup,
|
||||
PoolMetaNativeInspection, PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
|
||||
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
|
||||
POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup, PoolMetaNativeInspection,
|
||||
PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
|
||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
|
||||
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken,
|
||||
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path, new_disk,
|
||||
@@ -626,7 +617,7 @@ pub mod rebalance {
|
||||
pub mod rio {
|
||||
pub use crate::io_support::rio::{
|
||||
DecryptReader, DynReader, EncryptReader, HardLimitReader, HashReader, ReadStream, Reader, WriteEncryption, WritePlan,
|
||||
boxed_reader, compression_metadata_value, validate_compression_scheme, wrap_reader,
|
||||
boxed_reader, compression_metadata_value, wrap_reader,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -663,9 +654,7 @@ pub mod set_disk {
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::bucket::quota::reservation::{
|
||||
fail_next_quota_ledger_save_for_test, initialize_quota_ledger_for_test, retry_quota_reconciliation_for_test,
|
||||
};
|
||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||
pub use crate::set_disk::ConditionalReadBenchmarkMetadataGuard;
|
||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ use crate::bucket::metadata::BucketMetadata;
|
||||
use crate::bucket::metadata_sys::get_bucket_targets_config;
|
||||
use crate::bucket::metadata_sys::get_replication_config;
|
||||
use crate::bucket::remote_s3_client::{
|
||||
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, RemoteS3RetryPolicy,
|
||||
UploadResponseDeadline, build_remote_s3_client,
|
||||
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client,
|
||||
};
|
||||
use crate::bucket::replication::{ObjectLockIntegrity, object_lock_put_integrity, replication_etags_match};
|
||||
use crate::bucket::replication::{ReplicationStatusType, ReplicationTargetConfigBridge};
|
||||
@@ -120,7 +119,6 @@ impl From<&BucketTarget> for RemoteS3EndpointSpec {
|
||||
ca_cert_pem: (!target.ca_cert_pem.trim().is_empty()).then(|| target.ca_cert_pem.clone()),
|
||||
connect_timeout: None,
|
||||
read_timeout: None,
|
||||
operation_timeout: Some(TargetRequestTimeouts::from_env().request),
|
||||
// Replication has no retry budget of its own on the request path,
|
||||
// so it keeps the SDK's standard three attempts; stating it here
|
||||
// pins the behaviour to this line instead of an SDK default.
|
||||
@@ -1193,7 +1191,6 @@ impl BucketTargetSys {
|
||||
health_check_duration: target.health_check_duration,
|
||||
replicate_sync: target.replication_sync,
|
||||
client: Arc::new(client),
|
||||
timeouts: TargetRequestTimeouts::from_env(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1925,83 +1922,6 @@ impl std::fmt::Display for S3ClientError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replication request budgets, snapshotted when a target client is built.
|
||||
/// Streamed GET bodies are not subject to the upload-response budget.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TargetRequestTimeouts {
|
||||
request: Duration,
|
||||
complete: Duration,
|
||||
minimum_transfer_rate: u64,
|
||||
}
|
||||
|
||||
impl Default for TargetRequestTimeouts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
request: Duration::from_secs(60),
|
||||
complete: Duration::from_secs(15 * 60),
|
||||
minimum_transfer_rate: 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetRequestTimeouts {
|
||||
fn from_env() -> Self {
|
||||
let defaults = Self::default();
|
||||
let seconds = |name: &str, fallback: Duration| {
|
||||
let value = rustfs_utils::get_env_u64(name, fallback.as_secs());
|
||||
Duration::from_secs(if (1..=86400).contains(&value) {
|
||||
value
|
||||
} else {
|
||||
fallback.as_secs()
|
||||
})
|
||||
};
|
||||
let minimum_transfer_rate =
|
||||
rustfs_utils::get_env_u64("RUSTFS_REPLICATION_MIN_TRANSFER_RATE_BYTES_PER_SEC", defaults.minimum_transfer_rate);
|
||||
Self {
|
||||
request: seconds("RUSTFS_REPLICATION_REQUEST_TIMEOUT_SECS", defaults.request),
|
||||
complete: seconds("RUSTFS_REPLICATION_COMPLETE_TIMEOUT_SECS", defaults.complete),
|
||||
minimum_transfer_rate: if minimum_transfer_rate == 0 {
|
||||
defaults.minimum_transfer_rate
|
||||
} else {
|
||||
minimum_transfer_rate
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_timeout(self, size: i64) -> Result<Duration, S3ClientError> {
|
||||
let size = u64::try_from(size).map_err(|_| S3ClientError::new("replication upload size must be non-negative"))?;
|
||||
// The SDK can make three attempts. Leave transmission headroom for
|
||||
// all three; the separate response deadline still bounds a trickling
|
||||
// target after each upload finishes.
|
||||
let attempts = match REPLICATION_TARGET_RETRY_POLICY {
|
||||
RemoteS3RetryPolicy::Disabled => 1,
|
||||
RemoteS3RetryPolicy::Standard { max_attempts } => u64::from(max_attempts.max(1)),
|
||||
};
|
||||
let seconds = size
|
||||
.div_ceil(self.minimum_transfer_rate)
|
||||
.checked_mul(attempts)
|
||||
.ok_or_else(|| S3ClientError::new("replication upload timeout exceeds supported duration"))?;
|
||||
let timeout = self
|
||||
.request
|
||||
.checked_add(Duration::from_secs(seconds))
|
||||
.ok_or_else(|| S3ClientError::new("replication upload timeout exceeds supported duration"))?;
|
||||
if Instant::now().checked_add(timeout).is_none() {
|
||||
return Err(S3ClientError::new("replication upload timeout exceeds supported duration"));
|
||||
}
|
||||
Ok(timeout)
|
||||
}
|
||||
|
||||
fn upload_override(self, size: i64) -> Result<aws_sdk_s3::config::Builder, S3ClientError> {
|
||||
Ok(aws_sdk_s3::config::Builder::new()
|
||||
.timeout_config(
|
||||
aws_smithy_types::timeout::TimeoutConfig::builder()
|
||||
.operation_timeout(self.upload_timeout(size)?)
|
||||
.build(),
|
||||
)
|
||||
.interceptor(UploadResponseDeadline(self.request)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TargetClient {
|
||||
pub endpoint: String,
|
||||
@@ -2015,7 +1935,6 @@ pub struct TargetClient {
|
||||
pub health_check_duration: Duration,
|
||||
pub replicate_sync: bool,
|
||||
pub client: Arc<S3Client>,
|
||||
pub timeouts: TargetRequestTimeouts,
|
||||
}
|
||||
|
||||
impl TargetClient {
|
||||
@@ -2441,11 +2360,12 @@ impl TargetClient {
|
||||
|
||||
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
|
||||
});
|
||||
let mut config = self.timeouts.upload_override(size)?;
|
||||
if forwards_source_checksum {
|
||||
config = config.request_checksum_calculation(aws_sdk_s3::config::RequestChecksumCalculation::WhenRequired);
|
||||
operation = operation.config_override(
|
||||
aws_sdk_s3::config::Builder::new()
|
||||
.request_checksum_calculation(aws_sdk_s3::config::RequestChecksumCalculation::WhenRequired),
|
||||
);
|
||||
}
|
||||
operation = operation.config_override(config);
|
||||
match operation.send().await {
|
||||
Ok(output) => {
|
||||
// Under SSE-KMS/DSSE or SSE-C the target's ETag is not the MD5
|
||||
@@ -2581,7 +2501,6 @@ impl TargetClient {
|
||||
}
|
||||
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
|
||||
})
|
||||
.config_override(self.timeouts.upload_override(size)?)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -2619,13 +2538,6 @@ impl TargetClient {
|
||||
}
|
||||
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
|
||||
})
|
||||
.config_override(
|
||||
aws_sdk_s3::config::Builder::new().timeout_config(
|
||||
aws_smithy_types::timeout::TimeoutConfig::builder()
|
||||
.operation_timeout(self.timeouts.complete)
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -3054,241 +2966,11 @@ mod tests {
|
||||
health_check_duration: Duration::from_secs(5),
|
||||
replicate_sync: false,
|
||||
client: Arc::new(client),
|
||||
timeouts: TargetRequestTimeouts::default(),
|
||||
},
|
||||
request_headers,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TimedResponseConnector {
|
||||
upload_delay: Duration,
|
||||
response_delay: Duration,
|
||||
prefix_bytes: usize,
|
||||
response: bytes::Bytes,
|
||||
frames: Arc<std::sync::atomic::AtomicUsize>,
|
||||
dropped: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
struct ResponseDrop(Arc<std::sync::atomic::AtomicUsize>);
|
||||
impl Drop for ResponseDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl SmithyHttpConnector for TimedResponseConnector {
|
||||
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
|
||||
let settings = self.clone();
|
||||
HttpConnectorFuture::new(async move {
|
||||
http_body_util::BodyExt::collect(request.into_body())
|
||||
.await
|
||||
.map_err(aws_smithy_runtime_api::client::result::ConnectorError::io)?;
|
||||
tokio::time::sleep(settings.upload_delay).await;
|
||||
let guard = ResponseDrop(Arc::clone(&settings.dropped));
|
||||
let stream = futures::stream::unfold(
|
||||
(settings.prefix_bytes, Some(settings.response), true, guard),
|
||||
move |(remaining, mut body, first, guard)| {
|
||||
let frames = Arc::clone(&settings.frames);
|
||||
async move {
|
||||
if remaining == 0 && body.is_none() {
|
||||
return None;
|
||||
}
|
||||
if !first {
|
||||
tokio::time::sleep(settings.response_delay).await;
|
||||
}
|
||||
let bytes = if remaining > 0 {
|
||||
bytes::Bytes::from_static(b" ")
|
||||
} else {
|
||||
body.take()?
|
||||
};
|
||||
frames.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Some((
|
||||
Ok::<_, std::io::Error>(http_body::Frame::data(bytes)),
|
||||
(remaining.saturating_sub(1), body, false, guard),
|
||||
))
|
||||
}
|
||||
},
|
||||
)
|
||||
.fuse();
|
||||
let body = SdkBody::from_body_1_x(http_body_util::StreamBody::new(stream));
|
||||
let mut response =
|
||||
HttpResponse::new(aws_smithy_runtime_api::http::StatusCode::try_from(200u16).expect("status"), body);
|
||||
response.headers_mut().insert("etag", "\"test-etag\"");
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn timed_response_target(
|
||||
upload_delay: Duration,
|
||||
response_delay: Duration,
|
||||
prefix_bytes: usize,
|
||||
response: &'static [u8],
|
||||
) -> (TargetClient, Arc<std::sync::atomic::AtomicUsize>, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let frames = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let dropped = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let connector = SharedHttpConnector::new(TimedResponseConnector {
|
||||
upload_delay,
|
||||
response_delay,
|
||||
prefix_bytes,
|
||||
response: bytes::Bytes::from_static(response),
|
||||
frames: Arc::clone(&frames),
|
||||
dropped: Arc::clone(&dropped),
|
||||
});
|
||||
let http_client = http_client_fn(move |_settings, _components| connector.clone());
|
||||
let (mut target, _) = header_recording_target_client(Vec::new());
|
||||
target.timeouts = TargetRequestTimeouts {
|
||||
request: Duration::from_secs(2),
|
||||
complete: Duration::from_secs(10),
|
||||
minimum_transfer_rate: 1024 * 1024,
|
||||
};
|
||||
target.client = Arc::new(S3Client::from_conf(
|
||||
target
|
||||
.client
|
||||
.config()
|
||||
.to_builder()
|
||||
.http_client(http_client)
|
||||
.retry_config(aws_sdk_s3::config::retry::RetryConfig::disabled())
|
||||
.timeout_config(
|
||||
aws_smithy_types::timeout::TimeoutConfig::builder()
|
||||
.operation_timeout(target.timeouts.request)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
));
|
||||
(target, frames, dropped)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_upload_budgets_preserve_large_transfers_and_reject_invalid_sizes() {
|
||||
let policy = TargetRequestTimeouts::default();
|
||||
assert_eq!(policy.upload_timeout(0).expect("empty upload"), Duration::from_secs(60));
|
||||
assert_eq!(
|
||||
policy.upload_timeout(5 * 1024 * 1024 * 1024).expect("maximum single PUT"),
|
||||
Duration::from_secs(15420)
|
||||
);
|
||||
assert!(policy.upload_timeout(-1).is_err());
|
||||
let slow = TargetRequestTimeouts {
|
||||
minimum_transfer_rate: 1,
|
||||
..policy
|
||||
};
|
||||
assert!(slow.upload_timeout(i64::MAX).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn replication_upload_response_deadline_is_independent_of_size_and_checksum_override() {
|
||||
for operation in ["put", "forwarded-checksum", "part"] {
|
||||
let (target, frames, dropped) = timed_response_target(Duration::from_secs(4), Duration::from_millis(500), 100, b"");
|
||||
let body = vec![b'x'; 2 * 1024 * 1024];
|
||||
let size = i64::try_from(body.len()).expect("size");
|
||||
let started = tokio::time::Instant::now();
|
||||
let error = if operation == "part" {
|
||||
target
|
||||
.put_object_part(
|
||||
"bucket",
|
||||
"key",
|
||||
"upload",
|
||||
1,
|
||||
size,
|
||||
ByteStream::from(body),
|
||||
&PutObjectPartOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("response deadline")
|
||||
} else {
|
||||
let mut opts = PutObjectOptions::default();
|
||||
if operation == "forwarded-checksum" {
|
||||
opts.user_metadata.insert(
|
||||
"x-amz-checksum-sha256".to_owned(),
|
||||
"OoJ3yNhRwv3wwtZoGqEIPrPX9xwTnfLl+ka0wStN1g0=".to_owned(),
|
||||
);
|
||||
}
|
||||
target
|
||||
.put_object("bucket", "key", size, ByteStream::from(body), &opts)
|
||||
.await
|
||||
.expect_err("response deadline")
|
||||
};
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_secs(6),
|
||||
"upload gets its full independent window: {error}"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < target.timeouts.upload_timeout(size).expect("budget"),
|
||||
"response must end before the larger total deadline"
|
||||
);
|
||||
assert!(
|
||||
frames.load(std::sync::atomic::Ordering::SeqCst) >= 2,
|
||||
"trickle delivered progress before failure"
|
||||
);
|
||||
assert_eq!(dropped.load(std::sync::atomic::Ordering::SeqCst), 1, "transport body released");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn replication_slow_upload_preserves_transfer_window_and_streaming_get_body() {
|
||||
let (target, _, _) = timed_response_target(Duration::from_secs(4), Duration::from_millis(100), 5, b"");
|
||||
let body = vec![b'x'; 2 * 1024 * 1024];
|
||||
let size = i64::try_from(body.len()).expect("size");
|
||||
target
|
||||
.put_object("bucket", "key", size, ByteStream::from(body), &PutObjectOptions::default())
|
||||
.await
|
||||
.expect("slow upload, prompt response");
|
||||
let (target, _, dropped) = timed_response_target(Duration::ZERO, Duration::from_secs(1), 5, b"payload");
|
||||
let response = target
|
||||
.get_object("bucket", "key", None, None, None, HeaderMap::new())
|
||||
.await
|
||||
.expect("GET headers");
|
||||
let started = tokio::time::Instant::now();
|
||||
let body = response
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("streamed GET remains outside upload deadline")
|
||||
.into_bytes();
|
||||
assert!(started.elapsed() > target.timeouts.request);
|
||||
assert_eq!(&body[..], b" payload");
|
||||
assert_eq!(dropped.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn replication_complete_processing_whitespace_has_a_separate_bounded_window() {
|
||||
const RESULT: &[u8] = b"<CompleteMultipartUploadResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><ETag>\"etag\"</ETag></CompleteMultipartUploadResult>";
|
||||
for prefix in [5, 100] {
|
||||
let (target, frames, dropped) = timed_response_target(Duration::ZERO, Duration::from_secs(1), prefix, RESULT);
|
||||
let started = tokio::time::Instant::now();
|
||||
let result = target
|
||||
.complete_multipart_upload(
|
||||
"bucket",
|
||||
"key",
|
||||
"upload",
|
||||
vec![CompletedPart::builder().part_number(1).e_tag("etag").build()],
|
||||
&PutObjectOptions::default(),
|
||||
)
|
||||
.await;
|
||||
if prefix == 5 {
|
||||
assert!(result.is_ok(), "legal processing whitespace: {result:?}");
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
assert!(started.elapsed() <= Duration::from_secs(11));
|
||||
}
|
||||
assert!(started.elapsed() > target.timeouts.request);
|
||||
assert!(frames.load(std::sync::atomic::Ordering::SeqCst) >= 2);
|
||||
assert_eq!(dropped.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn replication_control_operation_deadline_covers_response_trickle() {
|
||||
let (target, frames, dropped) =
|
||||
timed_response_target(Duration::ZERO, Duration::from_millis(500), 100, b"<VersioningConfiguration/>");
|
||||
let started = tokio::time::Instant::now();
|
||||
assert!(target.get_bucket_versioning("bucket").await.is_err());
|
||||
assert!(started.elapsed() <= Duration::from_secs(3));
|
||||
assert!(frames.load(std::sync::atomic::Ordering::SeqCst) >= 2);
|
||||
assert_eq!(dropped.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
fn streaming_test_body(payload: &'static [u8]) -> ByteStream {
|
||||
let stream = tokio_util::io::ReaderStream::new(std::io::Cursor::new(payload));
|
||||
let body = http_body_util::StreamBody::new(futures::StreamExt::map(stream, |r| r.map(http_body::Frame::data)));
|
||||
@@ -3597,7 +3279,6 @@ mod tests {
|
||||
health_check_duration: Duration::from_secs(5),
|
||||
replicate_sync: false,
|
||||
client: Arc::new(client),
|
||||
timeouts: TargetRequestTimeouts::default(),
|
||||
},
|
||||
request_uris,
|
||||
)
|
||||
@@ -3910,7 +3591,6 @@ mod tests {
|
||||
health_check_duration: Duration::from_secs(5),
|
||||
replicate_sync: false,
|
||||
client: Arc::new(client),
|
||||
timeouts: TargetRequestTimeouts::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5487,14 +5487,6 @@ pub async fn put_restore_opts(
|
||||
if !oi.user_tags.is_empty() {
|
||||
meta.insert(AMZ_OBJECT_TAGGING.to_string(), (*oi.user_tags).clone());
|
||||
}
|
||||
if let Some(expires) = oi.expires {
|
||||
meta.insert(
|
||||
"expires".to_string(),
|
||||
expires
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(std::io::Error::other)?,
|
||||
);
|
||||
}
|
||||
let restore_expiry = lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), rreq.days.unwrap_or(1));
|
||||
meta.insert(
|
||||
metadata_keys::RESTORE.to_string(),
|
||||
@@ -5514,6 +5506,7 @@ pub async fn put_restore_opts(
|
||||
// computed MD5 is not the object's public plaintext ETag.
|
||||
preserve_etag: oi.etag.clone(),
|
||||
shard_integrity_write_mode: Some(oi.shard_integrity_write_mode()),
|
||||
//expires: oi.expires,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -7151,25 +7144,6 @@ mod tests {
|
||||
0,
|
||||
"tier generation lease should release after EOF"
|
||||
);
|
||||
let unread = get_transitioned_object_reader_with_tier_manager(
|
||||
&object_info.bucket,
|
||||
&object_info.name,
|
||||
&None,
|
||||
&HeaderMap::new(),
|
||||
&object_info,
|
||||
&ObjectOptions::default(),
|
||||
&manager,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("open an unread transitioned response");
|
||||
assert_eq!(TierConfigMgr::active_operation_lease_count(&manager, &tier).await, 1);
|
||||
drop(unread);
|
||||
assert_eq!(
|
||||
TierConfigMgr::active_operation_lease_count(&manager, &tier).await,
|
||||
0,
|
||||
"canceling an unread response must release the tier lease"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
|
||||
@@ -256,38 +256,6 @@ pub const BUCKET_POLICY_CONFIG: &str = "policy.json";
|
||||
pub const BUCKET_NOTIFICATION_CONFIG: &str = "notification.xml";
|
||||
pub const BUCKET_LIFECYCLE_CONFIG: &str = "lifecycle.xml";
|
||||
pub const BUCKET_SSECONFIG: &str = "bucket-encryption.xml";
|
||||
/// Shared tag-entry validation for object requests and persisted bucket tags.
|
||||
pub fn validate_tag_set_entries(tag_set: &[s3s::dto::Tag]) -> std::result::Result<(), &'static str> {
|
||||
fn is_valid_tag_text(value: &str) -> bool {
|
||||
static VALID_TAG_TEXT: std::sync::OnceLock<std::result::Result<regex::Regex, regex::Error>> = std::sync::OnceLock::new();
|
||||
VALID_TAG_TEXT
|
||||
.get_or_init(|| regex::Regex::new(r"^[\p{L}\p{N}\p{Z}+\-=._:/@]*$"))
|
||||
.as_ref()
|
||||
.is_ok_and(|pattern| pattern.is_match(value))
|
||||
}
|
||||
let mut keys = std::collections::HashSet::with_capacity(tag_set.len());
|
||||
for tag in tag_set {
|
||||
let key = tag
|
||||
.key
|
||||
.as_deref()
|
||||
.filter(|key| !key.is_empty())
|
||||
.ok_or("Tag key cannot be empty")?;
|
||||
if key.encode_utf16().count() > 128 || !is_valid_tag_text(key) {
|
||||
return Err("The TagKey you have provided is invalid");
|
||||
}
|
||||
let value = tag.value.as_deref().ok_or("Tag value cannot be null")?;
|
||||
if value.encode_utf16().count() > 256 || !is_valid_tag_text(value) {
|
||||
return Err("The TagValue you have provided is invalid");
|
||||
}
|
||||
if !keys.insert(key) {
|
||||
return Err("Cannot provide multiple Tags with the same key");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub const MAX_BUCKET_TAGS: usize = 50;
|
||||
|
||||
pub const BUCKET_TAGGING_CONFIG: &str = "tagging.xml";
|
||||
pub const BUCKET_QUOTA_CONFIG_FILE: &str = "quota.json";
|
||||
pub const OBJECT_LOCK_CONFIG: &str = "object-lock.xml";
|
||||
@@ -1022,20 +990,6 @@ impl BucketMetadata {
|
||||
self.encryption_config_updated_at = updated;
|
||||
}
|
||||
BUCKET_TAGGING_CONFIG => {
|
||||
if !data.is_empty() {
|
||||
let tagging = deserialize::<Tagging>(&data).map_err(|_| {
|
||||
Error::InvalidArgument(self.name.clone(), String::new(), "Invalid bucket tagging XML".to_owned())
|
||||
})?;
|
||||
if tagging.tag_set.len() > MAX_BUCKET_TAGS {
|
||||
return Err(Error::InvalidArgument(
|
||||
self.name.clone(),
|
||||
String::new(),
|
||||
"Cannot have more than 50 tags per bucket".to_owned(),
|
||||
));
|
||||
}
|
||||
validate_tag_set_entries(&tagging.tag_set)
|
||||
.map_err(|message| Error::InvalidArgument(self.name.clone(), String::new(), message.to_owned()))?;
|
||||
}
|
||||
self.tagging_config_xml = data;
|
||||
// Drop the parsed form (like lifecycle above) so clearing the
|
||||
// payload can't leave stale parsed tags to be cached.
|
||||
@@ -1830,31 +1784,6 @@ mod test {
|
||||
/// backlog#2292: a replicated config is stamped with the source
|
||||
/// `updated_at` it was given, not the local clock, while the plain
|
||||
/// `update_config` entry keeps stamping the local clock.
|
||||
#[test]
|
||||
fn bucket_tag_updates_reject_unreadable_shapes_without_replacing_metadata() {
|
||||
let mut metadata = BucketMetadata::new("validated-tags");
|
||||
let valid = br#"<Tagging><TagSet><Tag><Key>team</Key><Value></Value></Tag></TagSet></Tagging>"#.to_vec();
|
||||
metadata
|
||||
.update_config(BUCKET_TAGGING_CONFIG, valid.clone())
|
||||
.expect("empty value is valid");
|
||||
let updated = metadata.tagging_config_updated_at;
|
||||
for invalid in [
|
||||
"<Tagging><TagSet><Tag><Key>team</Key></Tag></TagSet></Tagging>".to_owned(),
|
||||
"<Tagging><TagSet><Tag><Key></Key><Value>x</Value></Tag></TagSet></Tagging>".to_owned(),
|
||||
"<Tagging><TagSet><Tag><Key>team</Key><Value>x</Value></Tag><Tag><Key>team</Key><Value>y</Value></Tag></TagSet></Tagging>".to_owned(),
|
||||
format!("<Tagging><TagSet>{}</TagSet></Tagging>", (0..51).map(|i| format!("<Tag><Key>k{i}</Key><Value>v</Value></Tag>")).collect::<String>()),
|
||||
] {
|
||||
assert!(metadata.update_config_at(BUCKET_TAGGING_CONFIG, invalid.into_bytes(), updated + time::Duration::hours(1)).is_err());
|
||||
assert_eq!(metadata.tagging_config_xml, valid);
|
||||
assert_eq!(metadata.tagging_config_updated_at, updated);
|
||||
}
|
||||
metadata.tagging_config_xml = b"legacy corrupt tags".to_vec();
|
||||
metadata
|
||||
.update_config(BUCKET_TAGGING_CONFIG, Vec::new())
|
||||
.expect("deletion repairs legacy corruption");
|
||||
assert!(metadata.tagging_config_xml.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_config_at_stamps_the_given_time_and_update_config_stamps_now() {
|
||||
let source_time = OffsetDateTime::now_utc() - time::Duration::hours(3);
|
||||
|
||||
@@ -3428,7 +3428,7 @@ mod tests {
|
||||
),
|
||||
(
|
||||
BUCKET_TAGGING_CONFIG,
|
||||
r#"<Tagging><TagSet><Tag><Key>environment</Key><Value>测试-rust</Value></Tag></TagSet></Tagging>"#.as_bytes(),
|
||||
r#"<Tagging><TagSet><Tag><Key>environment</Key><Value>测试-🦀</Value></Tag></TagSet></Tagging>"#.as_bytes(),
|
||||
),
|
||||
(BUCKET_REPLICATION_CONFIG, NEW_WRITER_REPLICATION_XML),
|
||||
(
|
||||
@@ -4556,13 +4556,8 @@ mod tests {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("bucket volume should be created");
|
||||
}
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
if config_file == crate::bucket::metadata::BUCKET_TAGGING_CONFIG {
|
||||
// Model legacy persisted corruption without passing the write-time validator.
|
||||
bm.tagging_config_xml = raw.to_vec();
|
||||
} else {
|
||||
bm.update_config(config_file, raw.to_vec())
|
||||
.expect("raw config should be stored");
|
||||
}
|
||||
bm.update_config(config_file, raw.to_vec())
|
||||
.expect("raw config should be stored");
|
||||
sys.persist_new_and_set(bm).await.expect("initial metadata should persist");
|
||||
}
|
||||
|
||||
@@ -5743,8 +5738,7 @@ mod tests {
|
||||
// update only needs one interleaving to show up.
|
||||
const ROUNDS: usize = 8;
|
||||
for round in 0..ROUNDS {
|
||||
let tagging =
|
||||
format!("<Tagging><TagSet><Tag><Key>round</Key><Value>{round}</Value></Tag></TagSet></Tagging>").into_bytes();
|
||||
let tagging = format!("<Tagging><Round>{round}</Round></Tagging>").into_bytes();
|
||||
let policy = format!(r#"{{"Version":"2012-10-17","Round":{round}}}"#).into_bytes();
|
||||
|
||||
let start = Arc::new(tokio::sync::Barrier::new(2));
|
||||
@@ -5817,7 +5811,7 @@ mod tests {
|
||||
|
||||
let blocked = tokio::spawn({
|
||||
let node_b = node_b.clone();
|
||||
async move { update_with_sys(node_b, bucket, BUCKET_TAGGING_CONFIG, b"<Tagging><TagSet/></Tagging>".to_vec()).await }
|
||||
async move { update_with_sys(node_b, bucket, BUCKET_TAGGING_CONFIG, b"<Tagging/>".to_vec()).await }
|
||||
});
|
||||
|
||||
// Long enough for the write to have finished had it not waited: the
|
||||
|
||||
@@ -119,21 +119,6 @@ async fn durable_quota_reporting_retires_degraded_floors_without_changing_ledger
|
||||
// Exercise the real storage entry point before testing the new
|
||||
// reporting semantics: the same ledger must admit and refuse
|
||||
// these writes even when a scanner floor claims a million bytes.
|
||||
if protocol == 1 {
|
||||
let mut bootstrap = PutObjReader::from_vec(vec![0x5a; 512]);
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.put_object(&bucket, "bootstrap", &mut bootstrap, &ObjectOptions::default())
|
||||
.await,
|
||||
Err(StorageError::SlowDown)
|
||||
),
|
||||
"a missing authoritative ledger must not admit a write from scanner reporting"
|
||||
);
|
||||
crate::bucket::quota::reservation::retry_quota_reconciliation_for_test(store.clone(), &bucket)
|
||||
.await
|
||||
.expect("bootstrap authoritative usage through the production worker");
|
||||
}
|
||||
let mut too_large = PutObjReader::from_vec(vec![0x5a; 8192]);
|
||||
assert!(
|
||||
matches!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,9 +40,7 @@ use aws_smithy_runtime_api::client::http::{
|
||||
HttpConnector as SmithyHttpConnector, HttpConnectorFuture, SharedHttpConnector, http_client_fn,
|
||||
};
|
||||
use aws_smithy_runtime_api::client::interceptors::Intercept;
|
||||
use aws_smithy_runtime_api::client::interceptors::context::{
|
||||
BeforeDeserializationInterceptorContextMut, BeforeTransmitInterceptorContextMut,
|
||||
};
|
||||
use aws_smithy_runtime_api::client::interceptors::context::BeforeTransmitInterceptorContextMut;
|
||||
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
|
||||
use aws_smithy_runtime_api::client::result::ConnectorError;
|
||||
use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
|
||||
@@ -157,8 +155,6 @@ pub struct RemoteS3EndpointSpec {
|
||||
pub ca_cert_pem: Option<String>,
|
||||
pub connect_timeout: Option<Duration>,
|
||||
pub read_timeout: Option<Duration>,
|
||||
/// Entire SDK call, including non-streaming response bodies and retries.
|
||||
pub operation_timeout: Option<Duration>,
|
||||
/// How many wire requests one logical call may cost. Every consumer
|
||||
/// declares it; see [`RemoteS3RetryPolicy`].
|
||||
pub retry: RemoteS3RetryPolicy,
|
||||
@@ -256,78 +252,6 @@ pub(crate) fn remote_sdk_credentials(credentials: &RemoteCredentials, now: Syste
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
/// Only installed on non-streaming upload operations. The budget begins at
|
||||
/// response headers, independently of the potentially long request upload.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct UploadResponseDeadline(pub Duration);
|
||||
|
||||
impl Intercept for UploadResponseDeadline {
|
||||
fn name(&self) -> &'static str {
|
||||
"RustfsUploadResponseDeadline"
|
||||
}
|
||||
|
||||
fn modify_before_deserialization(
|
||||
&self,
|
||||
context: &mut BeforeDeserializationInterceptorContextMut<'_>,
|
||||
_runtime_components: &RuntimeComponents,
|
||||
_cfg: &mut ConfigBag,
|
||||
) -> Result<(), BoxError> {
|
||||
let response = context.response_mut();
|
||||
let inner = std::mem::replace(response.body_mut(), SdkBody::taken());
|
||||
*response.body_mut() = SdkBody::from_body_1_x(UploadResponseBody {
|
||||
inner: Some(inner),
|
||||
deadline: Box::pin(tokio::time::sleep(self.0)),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadResponseBody {
|
||||
inner: Option<SdkBody>,
|
||||
deadline: std::pin::Pin<Box<tokio::time::Sleep>>,
|
||||
}
|
||||
|
||||
impl http_body::Body for UploadResponseBody {
|
||||
type Data = bytes::Bytes;
|
||||
type Error = BoxError;
|
||||
|
||||
fn poll_frame(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
|
||||
use std::future::Future as _;
|
||||
use std::task::Poll;
|
||||
if self.inner.is_none() {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
// The absolute response budget never renews on data, empty frames,
|
||||
// or trailers. A retry gets its own bounded response window.
|
||||
if self.deadline.as_mut().poll(cx).is_ready() {
|
||||
self.inner.take();
|
||||
return Poll::Ready(Some(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"replication response body deadline exceeded",
|
||||
)
|
||||
.into())));
|
||||
}
|
||||
let Some(inner) = self.inner.as_mut() else {
|
||||
return Poll::Ready(None);
|
||||
};
|
||||
let result = std::pin::Pin::new(inner).poll_frame(cx);
|
||||
if matches!(result, Poll::Ready(None) | Poll::Ready(Some(Err(_)))) {
|
||||
self.inner.take();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.inner.as_ref().is_none_or(http_body::Body::is_end_stream)
|
||||
}
|
||||
fn size_hint(&self) -> http_body::SizeHint {
|
||||
self.inner.as_ref().map(http_body::Body::size_hint).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a caller-identifying token to the SDK `User-Agent`. Runs after
|
||||
/// signing: SigV4 excludes `user-agent` from the canonical request, so the
|
||||
/// signature stays valid.
|
||||
@@ -385,7 +309,7 @@ pub async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_s
|
||||
config_builder = config_builder.http_client(http_client);
|
||||
}
|
||||
|
||||
if spec.connect_timeout.is_some() || spec.read_timeout.is_some() || spec.operation_timeout.is_some() {
|
||||
if spec.connect_timeout.is_some() || spec.read_timeout.is_some() {
|
||||
let mut timeouts = TimeoutConfig::builder();
|
||||
if let Some(connect_timeout) = spec.connect_timeout {
|
||||
timeouts = timeouts.connect_timeout(connect_timeout);
|
||||
@@ -393,9 +317,6 @@ pub async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_s
|
||||
if let Some(read_timeout) = spec.read_timeout {
|
||||
timeouts = timeouts.read_timeout(read_timeout);
|
||||
}
|
||||
if let Some(operation_timeout) = spec.operation_timeout {
|
||||
timeouts = timeouts.operation_timeout(operation_timeout);
|
||||
}
|
||||
config_builder = config_builder.timeout_config(timeouts.build());
|
||||
}
|
||||
|
||||
@@ -753,7 +674,6 @@ mod tests {
|
||||
ca_cert_pem: None,
|
||||
connect_timeout: None,
|
||||
read_timeout: None,
|
||||
operation_timeout: None,
|
||||
retry: RemoteS3RetryPolicy::Disabled,
|
||||
user_agent_suffix: "",
|
||||
}
|
||||
@@ -1107,16 +1027,11 @@ mod tests {
|
||||
spec.user_agent_suffix = "RustFS-Test/0.0";
|
||||
spec.connect_timeout = Some(Duration::from_secs(5));
|
||||
spec.read_timeout = Some(Duration::from_secs(5));
|
||||
spec.operation_timeout = Some(Duration::from_secs(12));
|
||||
let config = build_remote_s3_config(&spec)
|
||||
.await
|
||||
.expect("spec should build")
|
||||
.http_client(http_client)
|
||||
.build();
|
||||
assert_eq!(
|
||||
config.timeout_config().and_then(TimeoutConfig::operation_timeout),
|
||||
Some(Duration::from_secs(12))
|
||||
);
|
||||
S3Client::from_conf(config)
|
||||
.head_bucket()
|
||||
.bucket("bucket")
|
||||
|
||||
@@ -16,8 +16,8 @@ use super::replication_config_store::ReplicationConfigStore;
|
||||
use super::replication_error_boundary::{Error as EcstoreError, is_err_object_not_found, is_err_version_not_found};
|
||||
use super::replication_filemeta_boundary::{
|
||||
MrfOpKind, MrfReplicateEntry, REPLICATE_HEAL_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicatedTargetInfo,
|
||||
ReplicationGenerationSnapshot, ReplicationStatusType, ReplicationType, ReplicationWorkerOperation, ResyncDecision,
|
||||
replicate_decision_for_admitted_targets, replication_statuses_map, version_purge_statuses_map,
|
||||
ReplicationStatusType, ReplicationType, ReplicationWorkerOperation, ResyncDecision, replicate_decision_for_admitted_targets,
|
||||
replication_statuses_map, version_purge_statuses_map,
|
||||
};
|
||||
use super::replication_lock_boundary::ReplicationLockTiming;
|
||||
use super::replication_logging::{EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REPLICATION};
|
||||
@@ -1581,7 +1581,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
|
||||
/// Queues a replica task
|
||||
pub async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission {
|
||||
// A source generation already queued or being uploaded is not driven a
|
||||
// A version that is already queued or being uploaded is not driven a
|
||||
// second time: the scanner heal pass sees it as PENDING until the
|
||||
// first upload lands and would otherwise re-queue it every cycle
|
||||
// (backlog#2362). The key is released when the worker finishes, or
|
||||
@@ -2404,47 +2404,28 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
}
|
||||
|
||||
/// Object versions currently queued or being uploaded, keyed by bucket,
|
||||
/// object name, version and source generation. `queue_replica_task` admits a generation only once
|
||||
/// object name and version. `queue_replica_task` admits a version only once
|
||||
/// while it is in flight; the scanner heal pass and MRF replays that arrive
|
||||
/// in the meantime are `Skipped` instead of driving a second complete upload
|
||||
/// (backlog#2362). Entries are removed when the worker finishes the task or
|
||||
/// when no worker accepted it.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ReplicationInFlight {
|
||||
keys: std::sync::Mutex<std::collections::HashSet<ReplicationInFlightKey>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ReplicationInFlightKey {
|
||||
bucket: String,
|
||||
object: String,
|
||||
version_id: Option<uuid::Uuid>,
|
||||
generation: ReplicationGenerationSnapshot,
|
||||
}
|
||||
|
||||
impl From<&ReplicateObjectInfo> for ReplicationInFlightKey {
|
||||
fn from(info: &ReplicateObjectInfo) -> Self {
|
||||
Self {
|
||||
bucket: info.bucket.clone(),
|
||||
object: info.name.clone(),
|
||||
version_id: info.version_id,
|
||||
generation: info.replication_generation.clone(),
|
||||
}
|
||||
}
|
||||
keys: std::sync::Mutex<std::collections::HashSet<(String, String, Option<uuid::Uuid>)>>,
|
||||
}
|
||||
|
||||
impl ReplicationInFlight {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<ReplicationInFlightKey>> {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<(String, String, Option<uuid::Uuid>)>> {
|
||||
self.keys.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// Claim `ri`; a metadata mutation is independent of its earlier generation.
|
||||
/// Claim `ri`; `false` when the same version is already in flight.
|
||||
fn try_begin(&self, ri: &ReplicateObjectInfo) -> bool {
|
||||
self.lock().insert(ReplicationInFlightKey::from(ri))
|
||||
self.lock().insert((ri.bucket.clone(), ri.name.clone(), ri.version_id))
|
||||
}
|
||||
|
||||
fn finish(&self, ri: &ReplicateObjectInfo) {
|
||||
self.lock().remove(&ReplicationInFlightKey::from(ri));
|
||||
self.lock().remove(&(ri.bucket.clone(), ri.name.clone(), ri.version_id));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2457,21 +2438,26 @@ impl ReplicationInFlight {
|
||||
/// including when replication panics.
|
||||
struct ReplicationInFlightGuard {
|
||||
in_flight: Arc<ReplicationInFlight>,
|
||||
key: ReplicationInFlightKey,
|
||||
key: ReplicateObjectInfo,
|
||||
}
|
||||
|
||||
impl ReplicationInFlightGuard {
|
||||
fn new(in_flight: Arc<ReplicationInFlight>, ri: &ReplicateObjectInfo) -> Self {
|
||||
Self {
|
||||
in_flight,
|
||||
key: ReplicationInFlightKey::from(ri),
|
||||
key: ReplicateObjectInfo {
|
||||
bucket: ri.bucket.clone(),
|
||||
name: ri.name.clone(),
|
||||
version_id: ri.version_id,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReplicationInFlightGuard {
|
||||
fn drop(&mut self) {
|
||||
self.in_flight.lock().remove(&self.key);
|
||||
self.in_flight.finish(&self.key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4068,48 +4054,6 @@ mod tests {
|
||||
assert_eq!(current_queue(&pool, "in-flight-bucket").await, (3, 3 * 4096));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_replica_task_admits_metadata_changes_during_an_initial_upload() {
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
pool.workers.write().await.push(tx);
|
||||
let initial = ReplicateObjectInfo {
|
||||
bucket: "pending-tags-bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(uuid::Uuid::new_v4()),
|
||||
size: 4096,
|
||||
replication_generation: ReplicationGenerationSnapshot {
|
||||
mutation_id: Some(uuid::Uuid::new_v4().to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let metadata = ReplicateObjectInfo {
|
||||
op_type: ReplicationType::Metadata,
|
||||
user_tags: String::new(),
|
||||
replication_generation: ReplicationGenerationSnapshot {
|
||||
mutation_id: Some(uuid::Uuid::new_v4().to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
..initial.clone()
|
||||
};
|
||||
assert_eq!(pool.queue_replica_task(initial.clone()).await, ReplicationQueueAdmission::Queued);
|
||||
assert_eq!(pool.queue_replica_task(metadata.clone()).await, ReplicationQueueAdmission::Queued);
|
||||
assert_eq!(pool.queue_replica_task(metadata.clone()).await, ReplicationQueueAdmission::Skipped);
|
||||
assert_eq!(pool.in_flight.len(), 2);
|
||||
drop(ReplicationInFlightGuard::new(pool.in_flight.clone(), &initial));
|
||||
assert_eq!(pool.in_flight.len(), 1);
|
||||
assert_eq!(pool.queue_replica_task(metadata.clone()).await, ReplicationQueueAdmission::Skipped);
|
||||
for expected in [&initial, &metadata] {
|
||||
let ReplicationOperation::Object(queued) = rx.recv().await.expect("queued operation") else {
|
||||
panic!("expected object replication");
|
||||
};
|
||||
assert_eq!(queued.replication_generation, expected.replication_generation);
|
||||
}
|
||||
drop(ReplicationInFlightGuard::new(pool.in_flight.clone(), &metadata));
|
||||
assert_eq!(pool.in_flight.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_replica_task_releases_the_version_when_no_worker_accepts_it() {
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
||||
|
||||
@@ -6609,7 +6609,6 @@ mod tests {
|
||||
health_check_duration: std::time::Duration::from_secs(5),
|
||||
replicate_sync: false,
|
||||
client: Arc::new(aws_sdk_s3::Client::from_conf(config)),
|
||||
timeouts: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -259,10 +259,6 @@ pub fn check_object_name_for_length_and_slash(bucket: &str, object: &str) -> Res
|
||||
pub fn check_new_object_args(bucket: &str, object: &str, data_movement: bool) -> Result<()> {
|
||||
check_put_object_args(bucket, object)?;
|
||||
|
||||
if !data_movement && !is_meta_bucketname(bucket) && has_xml_invalid_control_characters(object) {
|
||||
return Err(StorageError::ObjectNameInvalid(bucket.to_owned(), object.to_owned()));
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") && !data_movement && !is_meta_bucketname(bucket) && object.contains('\\') {
|
||||
return Err(StorageError::InvalidArgument(
|
||||
bucket.to_owned(),
|
||||
@@ -274,14 +270,6 @@ pub fn check_new_object_args(bucket: &str, object: &str, data_movement: bool) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// New public keys must remain representable in an unencoded XML listing.
|
||||
/// Existing keys still use the read/delete validators so they can be removed.
|
||||
pub fn has_xml_invalid_control_characters(object: &str) -> bool {
|
||||
object
|
||||
.chars()
|
||||
.any(|character| character < ' ' && !matches!(character, '\t' | '\n' | '\r'))
|
||||
}
|
||||
|
||||
pub fn check_copy_obj_args(bucket: &str, object: &str) -> Result<()> {
|
||||
check_bucket_and_object_names(bucket, object)
|
||||
}
|
||||
|
||||
@@ -224,8 +224,6 @@ pub struct ListPathRawOptions {
|
||||
pub recursive: bool,
|
||||
pub incl_deleted: bool,
|
||||
pub skip_hidden_prefix_check: bool,
|
||||
pub hidden_entry_budget: usize,
|
||||
pub pending_prefix: Option<String>,
|
||||
pub filter_prefix: Option<String>,
|
||||
pub forward_to: Option<String>,
|
||||
pub min_disks: usize,
|
||||
@@ -262,8 +260,6 @@ impl Clone for ListPathRawOptions {
|
||||
recursive: self.recursive,
|
||||
incl_deleted: self.incl_deleted,
|
||||
skip_hidden_prefix_check: self.skip_hidden_prefix_check,
|
||||
hidden_entry_budget: self.hidden_entry_budget,
|
||||
pending_prefix: self.pending_prefix.clone(),
|
||||
filter_prefix: self.filter_prefix.clone(),
|
||||
forward_to: self.forward_to.clone(),
|
||||
min_disks: self.min_disks,
|
||||
@@ -293,8 +289,6 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
|
||||
recursive: opts.recursive,
|
||||
incl_deleted: opts.incl_deleted,
|
||||
skip_hidden_prefix_check: opts.skip_hidden_prefix_check,
|
||||
hidden_entry_budget: opts.hidden_entry_budget,
|
||||
pending_prefix: opts.pending_prefix.clone(),
|
||||
report_notfound: opts.report_not_found,
|
||||
filter_prefix: opts.filter_prefix.clone(),
|
||||
forward_to: opts.forward_to.clone(),
|
||||
@@ -307,15 +301,13 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
|
||||
}
|
||||
|
||||
/// How a merged raw listing stream ended.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ListPathRawOutcome {
|
||||
/// At least one producer stopped at its per-disk limit instead of at the
|
||||
/// end of its namespace, so the closed stream is not proof of exhaustion.
|
||||
/// Carried in-band by the metacache stream trailer, so remote producers
|
||||
/// report it as well.
|
||||
pub limit_reached: bool,
|
||||
/// Inclusive sorted boundary proved by every reader before a bounded stop.
|
||||
pub scan_cut: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
|
||||
@@ -734,8 +726,6 @@ async fn list_path_raw_inner(
|
||||
// Sticky across rounds: once any reader's trailer says its producer
|
||||
// stopped at the per-disk limit, the merged stream may hide more keys.
|
||||
let mut limit_reached = false;
|
||||
let mut scan_cut = None;
|
||||
let mut last_scanned_key: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let mut current = MetaCacheEntry::default();
|
||||
@@ -747,7 +737,7 @@ async fn list_path_raw_inner(
|
||||
// );
|
||||
|
||||
if revjob_rx.is_cancelled() {
|
||||
return Ok(ListPathRawOutcome { limit_reached, scan_cut });
|
||||
return Ok(ListPathRawOutcome { limit_reached });
|
||||
}
|
||||
|
||||
let mut top_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
|
||||
@@ -807,12 +797,7 @@ async fn list_path_raw_inner(
|
||||
continue;
|
||||
}
|
||||
// eof
|
||||
if r.limit_reached() == Some(true) {
|
||||
limit_reached = true;
|
||||
scan_cut = Some(last_scanned_key.clone().ok_or_else(|| {
|
||||
DiskError::other("bounded listing reader ended without a scan boundary")
|
||||
})?);
|
||||
}
|
||||
limit_reached |= r.limit_reached() == Some(true);
|
||||
at_eof += 1;
|
||||
// warn!("list_path_raw: peek eof, disk: {}", i);
|
||||
continue;
|
||||
@@ -886,7 +871,7 @@ async fn list_path_raw_inner(
|
||||
*r = MetacacheReader::new(detached_rd);
|
||||
continue;
|
||||
}
|
||||
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
|
||||
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -945,7 +930,7 @@ async fn list_path_raw_inner(
|
||||
if let Some(finished_fn) = opts.finished.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = finished_fn(&errs) => {}
|
||||
}
|
||||
}
|
||||
@@ -995,14 +980,6 @@ async fn list_path_raw_inner(
|
||||
return Err(classify_listing_quorum_failure(&failures));
|
||||
}
|
||||
|
||||
// The next head from a bounded reader is unknown. The previous
|
||||
// globally consumed group is a safe cut; consuming another reader's
|
||||
// later head would turn this absence of evidence into a skipped key.
|
||||
// Check quorum failures above before accepting the bounded result.
|
||||
if limit_reached {
|
||||
break;
|
||||
}
|
||||
|
||||
// Break if all at EOF or error.
|
||||
if at_eof + has_err == readers.len() {
|
||||
if has_err > 0
|
||||
@@ -1010,7 +987,7 @@ async fn list_path_raw_inner(
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = finished_fn(&errs) => {}
|
||||
}
|
||||
}
|
||||
@@ -1021,7 +998,6 @@ async fn list_path_raw_inner(
|
||||
break;
|
||||
}
|
||||
|
||||
last_scanned_key = Some(current.name.clone());
|
||||
if agree == readers.len() && !opts.preserve_replica_metadata {
|
||||
for r in readers.iter_mut() {
|
||||
if !r.skip_current() {
|
||||
@@ -1033,7 +1009,7 @@ async fn list_path_raw_inner(
|
||||
// warn!("list_path_raw: agreed_fn start, current: {:?}", ¤t.name);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
result = agreed_fn(current) => result?
|
||||
}
|
||||
// warn!("list_path_raw: agreed_fn done");
|
||||
@@ -1057,12 +1033,12 @@ async fn list_path_raw_inner(
|
||||
if let Some(partial_fn) = opts.partial.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = partial_fn(MetaCacheEntries(top_entries), &errs) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ListPathRawOutcome { limit_reached, scan_cut })
|
||||
Ok(ListPathRawOutcome { limit_reached })
|
||||
};
|
||||
revjobs.spawn(merge.in_current_span());
|
||||
|
||||
@@ -1228,24 +1204,6 @@ mod tests {
|
||||
assert!(!options.skip_total_timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_reader_options_preserve_hidden_scan_budget_and_pending_prefix() {
|
||||
let request = ListPathRawOptions {
|
||||
bucket: "versioned".to_owned(),
|
||||
hidden_entry_budget: 100,
|
||||
pending_prefix: Some("pending/".to_owned()),
|
||||
forward_to: Some("pending/0099".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
// Both walks are built once from the shared options; the per-job clone
|
||||
// must carry every field that the walk derives from the request.
|
||||
let job = request.clone();
|
||||
let walk = walk_dir_options(&job);
|
||||
assert_eq!(walk.hidden_entry_budget, 100);
|
||||
assert_eq!(walk.pending_prefix.as_deref(), request.pending_prefix.as_deref());
|
||||
assert_eq!(walk.forward_to.as_deref(), Some("pending/0099"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_empty_disks_returns_read_quorum() {
|
||||
let err = list_path_raw(CancellationToken::new(), ListPathRawOptions::default())
|
||||
@@ -1520,7 +1478,6 @@ mod tests {
|
||||
outcome.limit_reached,
|
||||
"a reader whose trailer reports a limit stop must mark the merged stream as possibly incomplete"
|
||||
);
|
||||
assert_eq!(outcome.scan_cut.as_deref(), Some("bucket/object"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1588,13 +1588,7 @@ fn verify_tonic_replay_scope_signature(audience: &str, path: &str, headers: &Hea
|
||||
if boot_epoch != tonic_rpc_boot_epoch() {
|
||||
return Err(std::io::Error::other("RPC boot epoch is stale"));
|
||||
}
|
||||
check_and_record_tonic_nonce(nonce, signed_at, path)?;
|
||||
// A body-bound request also carries a usable v2 signature. Consume both
|
||||
// nonces so stripping the replay-scope headers cannot authorize it again.
|
||||
if content_sha256 != UNSIGNED_PAYLOAD {
|
||||
verify_tonic_rpc_signature_with_strictness(audience, path, headers, true)?;
|
||||
}
|
||||
Ok(())
|
||||
check_and_record_tonic_nonce(nonce, signed_at, path)
|
||||
}
|
||||
|
||||
/// Verify gRPC authentication, preferring v2 without downgrade on malformed v2 metadata.
|
||||
@@ -2585,43 +2579,6 @@ mod tests {
|
||||
assert_eq!(path_error.to_string(), "Invalid RPC replay scope signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_scope_consumes_the_body_bound_v2_nonce() {
|
||||
ensure_test_rpc_secret();
|
||||
let audience = "node-a:9000";
|
||||
let path = "/node_service.NodeService/RecoverDeleteTransaction";
|
||||
let digest = "a".repeat(64);
|
||||
for v3_first in [true, false] {
|
||||
let mut headers =
|
||||
gen_tonic_signature_headers(audience, "node_service.NodeService", "RecoverDeleteTransaction", Some(&digest))
|
||||
.expect("body-bound v2 headers should build");
|
||||
let timestamp = headers[TIMESTAMP_HEADER].to_str().expect("timestamp").to_string();
|
||||
headers.extend(
|
||||
gen_tonic_replay_scope_headers(audience, path, ×tamp, &digest, tonic_rpc_boot_epoch())
|
||||
.expect("replay-scope headers should build"),
|
||||
);
|
||||
let mut downgraded = headers.clone();
|
||||
for header in [
|
||||
RPC_REPLAY_SCOPE_VERSION_HEADER,
|
||||
RPC_REPLAY_SCOPE_SIGNATURE_HEADER,
|
||||
RPC_REPLAY_SCOPE_NONCE_HEADER,
|
||||
RPC_BOOT_EPOCH_HEADER,
|
||||
] {
|
||||
downgraded.remove(header);
|
||||
}
|
||||
let (first, replay) = if v3_first {
|
||||
(&headers, &downgraded)
|
||||
} else {
|
||||
(&downgraded, &headers)
|
||||
};
|
||||
verify_tonic_rpc_signature_with_policy(audience, path, first, true, false, false)
|
||||
.expect("the first delivery must be accepted");
|
||||
let error = verify_tonic_rpc_signature_with_policy(audience, path, replay, true, false, false)
|
||||
.expect_err("a mutation cannot replay through the other signature generation");
|
||||
assert_eq!(error.to_string(), "RPC request replay detected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_scope_rejects_partial_metadata_and_stale_epoch_without_fallback() {
|
||||
ensure_test_rpc_secret();
|
||||
|
||||
@@ -2014,21 +2014,9 @@ impl PeerRestClient {
|
||||
}
|
||||
result
|
||||
})
|
||||
.await;
|
||||
self.settle_bucket_metadata_reload(bucket, result).await
|
||||
}
|
||||
|
||||
async fn settle_bucket_metadata_reload(
|
||||
&self,
|
||||
bucket: &str,
|
||||
result: std::result::Result<Result<()>, tokio::time::error::Elapsed>,
|
||||
) -> Result<()> {
|
||||
match result {
|
||||
Ok(result) => self.finalize_result(result).await,
|
||||
// A local operation deadline does not establish that the peer is
|
||||
// unreachable. In particular, it must not gate IAM revocations.
|
||||
Err(_) => Err(Error::other(format!("load_bucket_metadata({bucket}) timed out"))),
|
||||
}
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(Error::other(format!("load_bucket_metadata({bucket}) timed out"))));
|
||||
self.finalize_result(result).await
|
||||
}
|
||||
|
||||
async fn load_bucket_metadata_once(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
|
||||
@@ -4765,31 +4753,6 @@ mod tests {
|
||||
assert!(client.offline.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bucket_metadata_deadline_does_not_gate_healthy_peer_notifications() {
|
||||
let client = test_peer_client();
|
||||
let elapsed = tokio::time::timeout(std::time::Duration::ZERO, std::future::pending::<Result<()>>()).await;
|
||||
let error = client
|
||||
.settle_bucket_metadata_reload("slow-policy", elapsed)
|
||||
.await
|
||||
.expect_err("local budget expires");
|
||||
assert!(error.to_string().contains("timed out"));
|
||||
assert!(
|
||||
!client.offline.load(Ordering::Acquire),
|
||||
"local work must not suppress subsequent IAM RPCs"
|
||||
);
|
||||
client
|
||||
.settle_bucket_metadata_reload("healthy", Ok(Ok(())))
|
||||
.await
|
||||
.expect("peer reply");
|
||||
let error = client
|
||||
.settle_bucket_metadata_reload("unreachable", Ok(Err(Error::from(tonic::Status::unavailable("connection refused")))))
|
||||
.await
|
||||
.expect_err("real transport failure");
|
||||
assert!(error.to_string().contains("connection refused"));
|
||||
assert!(client.offline.load(Ordering::Acquire), "transport failures must still enter recovery");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_rest_client_finalize_result_keeps_online_for_business_errors() {
|
||||
let client = test_peer_client();
|
||||
|
||||
@@ -1082,10 +1082,7 @@ impl RemoteDisk {
|
||||
if payload.len() > operation.max_wire_payload_bytes() {
|
||||
return Err(Error::other("delete recovery operation exceeds payload limit"));
|
||||
}
|
||||
let settle_timeout = if matches!(
|
||||
&operation,
|
||||
DeleteRecoveryRpcOperation::Settle { .. } | DeleteRecoveryRpcOperation::SettleBounded(_)
|
||||
) {
|
||||
let settle_timeout = if matches!(&operation, DeleteRecoveryRpcOperation::Settle { .. }) {
|
||||
Some(crate::disk::get_delete_recovery_timeout()?)
|
||||
} else {
|
||||
None
|
||||
@@ -2719,54 +2716,6 @@ impl RemoteDisk {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for RemoteDisk {
|
||||
async fn list_multipart_index_roots(
|
||||
&self,
|
||||
bucket: &str,
|
||||
marker: Option<&str>,
|
||||
) -> Result<crate::disk::multipart_index::MultipartIndexPage> {
|
||||
use crate::disk::multipart_index::{MULTIPART_INDEX_RPC_MAX_BYTES, MultipartIndexRpcReply, MultipartIndexRpcRequest};
|
||||
let expected_disk = self
|
||||
.id
|
||||
.lock()
|
||||
.await
|
||||
.filter(|id| !id.is_nil())
|
||||
.ok_or(DiskError::DiskNotFound)?;
|
||||
let query = MultipartIndexRpcRequest::new(bucket, marker);
|
||||
let endpoint = self.endpoint.to_string();
|
||||
let canonical = query.canonical_request(&endpoint)?;
|
||||
let opts = serde_json::to_string(&query)?;
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let mut client = self.get_client().await?;
|
||||
let mut request = Request::new(DiskInfoRequest {
|
||||
disk: endpoint.clone(),
|
||||
opts: opts.clone(),
|
||||
});
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
|
||||
let response = client.disk_info(request).await?.into_inner();
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
if response.disk_info.len() > MULTIPART_INDEX_RPC_MAX_BYTES {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
// An old server ignores the additive query and returns ordinary DiskInfo.
|
||||
// It cannot satisfy the dedicated reply schema or the fresh challenge.
|
||||
let reply: MultipartIndexRpcReply =
|
||||
serde_json::from_str(&response.disk_info).map_err(|_| DiskError::MethodNotAllowed)?;
|
||||
reply.validate(&query, expected_disk)?;
|
||||
crate::cluster::rpc::verify_tonic_rpc_response_proof(
|
||||
&query.canonical_response(&endpoint, &reply.page)?,
|
||||
&reply.proof,
|
||||
)
|
||||
.map_err(Error::other)?;
|
||||
Ok(reply.page)
|
||||
},
|
||||
get_drive_disk_info_timeout(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn supports_guarded_delete_versions(&self) -> Result<()> {
|
||||
let response = self.guarded_delete_versions_rpc("", Vec::new(), true).await?;
|
||||
if !response.errors.is_empty() || !response.item_errors.is_empty() {
|
||||
@@ -3680,7 +3629,13 @@ impl DiskAPI for RemoteDisk {
|
||||
);
|
||||
}
|
||||
let rpc_started = read_version_stage_timer(batch_read_version_attribution_enabled);
|
||||
let response = match client.batch_read_version(request).await {
|
||||
let response_result = client.batch_read_version(request).await;
|
||||
if let Some(rpc_started) = rpc_started {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_duration(
|
||||
rpc_started.elapsed(),
|
||||
);
|
||||
}
|
||||
let response = match response_result {
|
||||
Ok(response) => {
|
||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, rpc_started);
|
||||
response.into_inner()
|
||||
@@ -4350,38 +4305,6 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_delete_transactions_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
max_entries: u32,
|
||||
) -> Result<crate::disk::BoundedDeleteRecoveryDiscovery> {
|
||||
// Older peers reject this variant. Do not retry through the unbounded protocol;
|
||||
// they are reported as unsupported participants (rolling-upgrade contract).
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::DiscoverBounded {
|
||||
object: object.to_owned(),
|
||||
max_entries,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(DeleteRecoveryRpcReply::DiscoveredBounded(page)) => Ok(page),
|
||||
Ok(_) => Err(Error::FileCorrupt),
|
||||
Err(error)
|
||||
if matches!(error, Error::MethodNotAllowed | Error::UnsupportedDisk)
|
||||
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
|
||||
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
|
||||
{
|
||||
// No paginated fallback through the unbounded protocol; the peer
|
||||
// is an unsupported participant until it is upgraded.
|
||||
Err(Error::UnsupportedDisk)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -4417,62 +4340,6 @@ impl DiskAPI for RemoteDisk {
|
||||
_ => Err(Error::other("unexpected delete discovery reply")),
|
||||
}
|
||||
}
|
||||
async fn settle_delete_transaction_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
request: crate::disk::BoundedDeleteRecoverySettlement,
|
||||
_namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<crate::disk::BoundedDeleteRecoverySettlementReply> {
|
||||
match self
|
||||
.recover_delete_transaction(volume, DeleteRecoveryRpcOperation::SettleBounded(request))
|
||||
.await
|
||||
{
|
||||
Ok(DeleteRecoveryRpcReply::SettledBounded(reply)) => Ok(reply),
|
||||
Ok(_) => Err(Error::FileCorrupt),
|
||||
Err(error)
|
||||
if matches!(error, Error::MethodNotAllowed | Error::UnsupportedDisk)
|
||||
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
|
||||
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
|
||||
{
|
||||
// No paginated fallback through the unbounded protocol; the peer
|
||||
// is an unsupported participant until it is upgraded.
|
||||
Err(Error::UnsupportedDisk)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
async fn inspect_delete_transaction_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
max_entries: u32,
|
||||
) -> Result<crate::disk::BoundedDeleteRecoveryInspection> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::InspectBounded {
|
||||
object: object.to_owned(),
|
||||
transaction,
|
||||
max_entries,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(DeleteRecoveryRpcReply::InspectedBounded(page)) => Ok(page),
|
||||
Ok(_) => Err(Error::FileCorrupt),
|
||||
Err(error)
|
||||
if matches!(error, Error::MethodNotAllowed | Error::UnsupportedDisk)
|
||||
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
|
||||
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
|
||||
{
|
||||
// No paginated fallback through the unbounded protocol; the peer
|
||||
// is an unsupported participant until it is upgraded.
|
||||
Err(Error::UnsupportedDisk)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
async fn inspect_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -4695,7 +4562,10 @@ impl DiskAPI for RemoteDisk {
|
||||
read_multiple_req_bin: read_multiple_req_bin.into(),
|
||||
});
|
||||
|
||||
let response = client.read_multiple(request).await?.into_inner();
|
||||
let rpc_started = Instant::now();
|
||||
let response = client.read_multiple(request).await;
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_multiple_duration(rpc_started.elapsed());
|
||||
let response = response?.into_inner();
|
||||
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
@@ -4746,6 +4616,7 @@ impl DiskAPI for RemoteDisk {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "write_all")?;
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_request();
|
||||
let rpc_started = Instant::now();
|
||||
let response = match client.write_all(request).await {
|
||||
Ok(response) => response.into_inner(),
|
||||
Err(err) => {
|
||||
@@ -4754,6 +4625,7 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
};
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_duration(rpc_started.elapsed());
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_sent_bytes(data_len);
|
||||
|
||||
if !response.success {
|
||||
@@ -4851,6 +4723,7 @@ impl DiskAPI for RemoteDisk {
|
||||
});
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_request();
|
||||
let rpc_started = Instant::now();
|
||||
let response = match client.read_all(request).await {
|
||||
Ok(response) => response.into_inner(),
|
||||
Err(err) => {
|
||||
@@ -4859,6 +4732,8 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
};
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_duration(rpc_started.elapsed());
|
||||
|
||||
if !response.success {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_error();
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
@@ -4993,113 +4868,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_delete_discovery_lost_remote_reply_leaves_no_cursor() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("test peer must bind");
|
||||
let directory = tempfile::tempdir().expect("temporary disk");
|
||||
let endpoint = Endpoint::try_from(directory.path().to_str().expect("disk path")).expect("endpoint");
|
||||
let disk = Arc::new(
|
||||
crate::disk::local::LocalDisk::new(&endpoint, false)
|
||||
.await
|
||||
.expect("local disk"),
|
||||
);
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
let prefix = directory.path().join("bucket/prefix");
|
||||
std::fs::create_dir(&prefix).expect("prefix");
|
||||
for index in 0..64 {
|
||||
std::fs::create_dir(prefix.join(format!("child-{index}"))).expect("legitimate child");
|
||||
}
|
||||
let fixture = Arc::new(TestBoundedDiscovery {
|
||||
disk: Arc::clone(&disk),
|
||||
response_gate: TestFormatReadGate::default(),
|
||||
});
|
||||
*peer.peer.bounded_discovery.lock().expect("bounded fixture") = Some(Arc::clone(&fixture));
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
let task = tokio::spawn(async move { remote.discover_delete_transactions_bounded("bucket", "prefix", 8).await });
|
||||
tokio::time::timeout(Duration::from_secs(5), fixture.response_gate.entered.notified())
|
||||
.await
|
||||
.expect("disk completed bounded scan");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("caller cancelled").is_cancelled());
|
||||
assert_eq!(disk.public_delete_discovery_sessions_for_test(), 0);
|
||||
fixture.response_gate.release.cancel();
|
||||
peer.stop().await;
|
||||
assert_eq!(disk.public_delete_discovery_sessions_for_test(), 0);
|
||||
assert!(prefix.join("child-63").is_dir());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_index_old_disk_info_handler_is_not_a_ready_empty_index() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
remote
|
||||
.set_disk_id(Some(Uuid::new_v4()))
|
||||
.await
|
||||
.expect("expected disk identity");
|
||||
assert!(matches!(
|
||||
remote.list_multipart_index_roots("bucket", None).await,
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
));
|
||||
assert_eq!(
|
||||
peer.peer.disk_info_calls.load(Ordering::Acquire),
|
||||
1,
|
||||
"exercise the actual legacy JSON handler"
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_delete_discovery_old_peer_rejects_without_paginated_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
assert_eq!(
|
||||
remote
|
||||
.discover_delete_transactions_bounded("bucket", "prefix", 4096)
|
||||
.await
|
||||
.expect_err("old peer cannot prove a bounded inventory"),
|
||||
DiskError::UnsupportedDisk
|
||||
);
|
||||
let transaction = Uuid::new_v4();
|
||||
assert_eq!(
|
||||
remote
|
||||
.inspect_delete_transaction_bounded("bucket", "prefix", transaction, 65536)
|
||||
.await
|
||||
.expect_err("old peer cannot inspect with a budget"),
|
||||
DiskError::UnsupportedDisk
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.settle_delete_transaction_bounded(
|
||||
"bucket",
|
||||
crate::disk::BoundedDeleteRecoverySettlement {
|
||||
object: "prefix".to_owned(),
|
||||
transaction,
|
||||
expected_fingerprint: [0; 32],
|
||||
action: DeleteRecoveryAction::PublishCommit,
|
||||
max_entries: 65536,
|
||||
},
|
||||
None
|
||||
)
|
||||
.await
|
||||
.expect_err("old peer cannot settle with a budget"),
|
||||
DiskError::UnsupportedDisk
|
||||
);
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods"),
|
||||
vec!["/node_service.NodeService/RecoverDeleteTransaction"; 3]
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_source_recovery_old_peer_rejects_without_generic_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
@@ -5993,16 +5761,9 @@ mod tests {
|
||||
read_all_data: Bytes,
|
||||
fail_format_reads: Arc<AtomicBool>,
|
||||
next_format_read_gate: Arc<StdMutex<Option<Arc<TestFormatReadGate>>>>,
|
||||
bounded_discovery: Arc<StdMutex<Option<Arc<TestBoundedDiscovery>>>>,
|
||||
unsupported_methods: Arc<StdMutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestBoundedDiscovery {
|
||||
disk: Arc<crate::disk::local::LocalDisk>,
|
||||
response_gate: TestFormatReadGate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct TestFormatReadGate {
|
||||
entered: tokio::sync::Notify,
|
||||
@@ -6020,7 +5781,6 @@ mod tests {
|
||||
read_all_data,
|
||||
fail_format_reads: Arc::default(),
|
||||
next_format_read_gate: Arc::default(),
|
||||
bounded_discovery: Arc::default(),
|
||||
unsupported_methods: Arc::default(),
|
||||
}
|
||||
}
|
||||
@@ -6120,56 +5880,6 @@ mod tests {
|
||||
Ok(grpc.unary(method, request).await)
|
||||
})
|
||||
}
|
||||
"/node_service.NodeService/RecoverDeleteTransaction"
|
||||
if self.peer.bounded_discovery.lock().expect("bounded fixture").is_some() =>
|
||||
{
|
||||
#[derive(Clone)]
|
||||
struct BoundedSvc(Arc<TestBoundedDiscovery>);
|
||||
impl tonic::server::UnaryService<RecoverDeleteTransactionRequest> for BoundedSvc {
|
||||
type Response = rustfs_protos::proto_gen::node_service::RecoverDeleteTransactionResponse;
|
||||
type Future = Pin<Box<dyn Future<Output = std::result::Result<Response<Self::Response>, Status>> + Send>>;
|
||||
fn call(&mut self, request: Request<RecoverDeleteTransactionRequest>) -> Self::Future {
|
||||
let fixture = Arc::clone(&self.0);
|
||||
Box::pin(async move {
|
||||
let request = request.into_inner();
|
||||
let operation: DeleteRecoveryRpcOperation =
|
||||
rustfs_protos::decode_delete_recovery_payload(&request.operation)
|
||||
.map_err(|_| Status::invalid_argument("invalid bounded request"))?;
|
||||
let DeleteRecoveryRpcOperation::DiscoverBounded { object, max_entries } = operation else {
|
||||
return Err(Status::invalid_argument("unexpected fallback"));
|
||||
};
|
||||
let page = fixture
|
||||
.disk
|
||||
.discover_delete_transactions_bounded(&request.volume, &object, max_entries)
|
||||
.await
|
||||
.map_err(|_| Status::internal("bounded discovery failed"))?;
|
||||
// Lose the response only after the real disk operation has completed.
|
||||
fixture.response_gate.entered.notify_one();
|
||||
fixture.response_gate.release.cancelled().await;
|
||||
let result = rmp_serde::to_vec_named(&DeleteRecoveryRpcReply::DiscoveredBounded(page))
|
||||
.map_err(|_| Status::internal("encode bounded reply"))?;
|
||||
Ok(Response::new(rustfs_protos::proto_gen::node_service::RecoverDeleteTransactionResponse {
|
||||
success: true,
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
result: result.into(),
|
||||
error: None,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
let fixture = self
|
||||
.peer
|
||||
.bounded_discovery
|
||||
.lock()
|
||||
.expect("bounded fixture")
|
||||
.clone()
|
||||
.expect("configured fixture");
|
||||
Box::pin(async move {
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let mut grpc = tonic::server::Grpc::new(codec);
|
||||
Ok(grpc.unary(BoundedSvc(fixture), request).await)
|
||||
})
|
||||
}
|
||||
"/node_service.NodeService/ReadAll" => {
|
||||
#[derive(Clone)]
|
||||
struct ReadAllSvc(AuthenticatedReadPeer);
|
||||
|
||||
@@ -481,6 +481,14 @@ pub(crate) fn record_remote_disk_grpc_write_all_request() {
|
||||
.record_outgoing_request_for_operation_and_backend(INTERNODE_OPERATION_GRPC_WRITE_ALL, INTERNODE_TRANSPORT_BACKEND_GRPC);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_write_all_duration(duration: Duration) {
|
||||
global_internode_metrics().record_duration_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_WRITE_ALL,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_write_all_sent_bytes(bytes: usize) {
|
||||
global_internode_metrics().record_sent_bytes_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_WRITE_ALL,
|
||||
@@ -521,6 +529,14 @@ pub(crate) fn record_remote_disk_grpc_read_all_request() {
|
||||
.record_outgoing_request_for_operation_and_backend(INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_TRANSPORT_BACKEND_GRPC);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_read_all_duration(duration: Duration) {
|
||||
global_internode_metrics().record_duration_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_READ_ALL,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
fn read_version_request_mode(opts: &ReadOptions) -> ReadVersionRequestMode {
|
||||
match (opts.read_data, opts.healing, opts.incl_free_versions) {
|
||||
@@ -598,6 +614,17 @@ pub(crate) fn record_remote_disk_grpc_batch_read_version_stage(stage: &'static s
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_duration(duration: Duration) {
|
||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
global_internode_metrics().record_duration_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_error() {
|
||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
||||
return;
|
||||
@@ -690,6 +717,14 @@ pub(crate) fn record_remote_disk_grpc_read_multiple_recv_bytes(bytes: usize) {
|
||||
record_grpc_payload_size(INTERNODE_OPERATION_GRPC_READ_MULTIPLE, bytes);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_read_multiple_duration(duration: Duration) {
|
||||
global_internode_metrics().record_duration_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
/// Payload-size threshold (bytes) above which a unary internode gRPC response is counted as a
|
||||
/// "large payload" for alerting. Env-overridable via `RUSTFS_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES`.
|
||||
fn internode_rpc_large_payload_warn_bytes() -> usize {
|
||||
|
||||
@@ -317,9 +317,8 @@ impl Sets {
|
||||
&self.ctx
|
||||
}
|
||||
|
||||
/// Stop endpoint reconnection and wait for any in-flight pass before a test owns disk probes.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub async fn stop_endpoint_monitor_for_test(&self) {
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn stop_endpoint_monitor_for_test(&self) {
|
||||
if let Some(exit_signal) = &self.exit_signal {
|
||||
let _ = exit_signal.send(());
|
||||
// Wait for an in-flight reconnect pass as well as its receiver to exit.
|
||||
|
||||
@@ -55,20 +55,6 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Join the local disk's actual bootstrap worker for deterministic coverage tests.
|
||||
/// Completion may mean legacy fallback after a failed scan; callers must assert
|
||||
/// the expected index result separately. Remote disks have no local worker.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub async fn wait_for_multipart_index_bootstrap(disk: &crate::disk::DiskStore) -> Result<()> {
|
||||
match disk.as_ref() {
|
||||
crate::disk::Disk::Local(local) => {
|
||||
local.disk.wait_for_multipart_index_bootstrap().await;
|
||||
Ok(())
|
||||
}
|
||||
crate::disk::Disk::Remote(_) => Err(DiskError::MethodNotAllowed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk health status constants
|
||||
const DISK_HEALTH_OK: u32 = 0;
|
||||
const DISK_HEALTH_FAULTY: u32 = 1;
|
||||
@@ -2457,7 +2443,6 @@ impl LocalDiskWrapper {
|
||||
object: &str,
|
||||
transaction: uuid::Uuid,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
|
||||
) -> Result<Option<(crate::disk::DeleteRecoveryJournal, bool, bool)>> {
|
||||
self.track_disk_health_mutation(
|
||||
"settle_delete_transaction",
|
||||
@@ -2465,7 +2450,7 @@ impl LocalDiskWrapper {
|
||||
|| {
|
||||
Box::pin(async {
|
||||
self.disk
|
||||
.try_publish_prepared_delete_local_with_budget(volume, object, transaction, namespace_owner, work_budget)
|
||||
.try_publish_prepared_delete_local(volume, object, transaction, namespace_owner)
|
||||
.await
|
||||
})
|
||||
},
|
||||
@@ -2481,7 +2466,6 @@ impl LocalDiskWrapper {
|
||||
transaction: uuid::Uuid,
|
||||
journal: &crate::disk::DeleteRecoveryJournal,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
|
||||
) -> Result<bool> {
|
||||
self.track_disk_health_mutation(
|
||||
"settle_delete_transaction",
|
||||
@@ -2489,14 +2473,7 @@ impl LocalDiskWrapper {
|
||||
|| {
|
||||
Box::pin(async {
|
||||
self.disk
|
||||
.try_finish_and_finalize_prepared_delete_local_with_budget(
|
||||
volume,
|
||||
object,
|
||||
transaction,
|
||||
journal,
|
||||
namespace_owner,
|
||||
work_budget,
|
||||
)
|
||||
.try_finish_and_finalize_prepared_delete_local(volume, object, transaction, journal, namespace_owner)
|
||||
.await
|
||||
})
|
||||
},
|
||||
@@ -2751,18 +2728,6 @@ impl LocalDiskWrapper {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for LocalDiskWrapper {
|
||||
async fn list_multipart_index_roots(
|
||||
&self,
|
||||
bucket: &str,
|
||||
marker: Option<&str>,
|
||||
) -> Result<crate::disk::multipart_index::MultipartIndexPage> {
|
||||
if self.health.is_faulty() {
|
||||
return Err(DiskError::FaultyDisk);
|
||||
}
|
||||
self.check_disk_stale().await?;
|
||||
self.disk.list_multipart_index_roots(bucket, marker).await
|
||||
}
|
||||
|
||||
async fn supports_guarded_delete_versions(&self) -> Result<()> {
|
||||
// The local implementation shares this binary's capability. Availability
|
||||
// and disk identity remain mutation checks and write-quorum inputs.
|
||||
@@ -2816,62 +2781,6 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn discover_delete_transactions_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
max_entries: u32,
|
||||
) -> Result<crate::disk::BoundedDeleteRecoveryDiscovery> {
|
||||
self.track_disk_health_with_op(
|
||||
"discover_delete_transactions",
|
||||
|| async {
|
||||
self.disk
|
||||
.discover_delete_transactions_bounded(volume, object, max_entries)
|
||||
.await
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn inspect_delete_transaction_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: uuid::Uuid,
|
||||
max_entries: u32,
|
||||
) -> Result<crate::disk::BoundedDeleteRecoveryInspection> {
|
||||
self.track_disk_health_with_op(
|
||||
"inspect_delete_transaction",
|
||||
|| async {
|
||||
self.disk
|
||||
.inspect_delete_transaction_bounded(volume, object, transaction, max_entries)
|
||||
.await
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn settle_delete_transaction_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
request: crate::disk::BoundedDeleteRecoverySettlement,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<crate::disk::BoundedDeleteRecoverySettlementReply> {
|
||||
self.track_disk_health_mutation(
|
||||
"settle_delete_transaction",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async {
|
||||
self.disk
|
||||
.settle_delete_transaction_bounded(volume, request, namespace_owner)
|
||||
.await
|
||||
},
|
||||
crate::disk::get_delete_recovery_timeout()?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -3840,11 +3749,7 @@ mod tests {
|
||||
assert!(futures::poll!(&mut operation).is_pending());
|
||||
time::advance(SLOW_DISK_OPERATION_THRESHOLD - Duration::from_millis(1)).await;
|
||||
assert!(futures::poll!(&mut operation).is_pending());
|
||||
assert!(
|
||||
!logs.contents().contains("disk_operation_slow") && !logs.contents().contains("WARN"),
|
||||
"normal disk latency should not warn: {}",
|
||||
logs.contents()
|
||||
);
|
||||
assert!(logs.contents().is_empty(), "normal disk latency should not warn");
|
||||
time::advance(Duration::from_millis(1)).await;
|
||||
assert!(futures::poll!(&mut operation).is_pending());
|
||||
let warning = logs.contents();
|
||||
|
||||
@@ -172,8 +172,6 @@ pub enum DiskError {
|
||||
|
||||
#[error("guarded batch deletion is not supported by this disk")]
|
||||
GuardedDeleteUnsupported,
|
||||
#[error("foreground delete recovery work limit reached")]
|
||||
DeleteRecoveryWorkLimit,
|
||||
|
||||
#[error("erasure write quorum")]
|
||||
ErasureWriteQuorum,
|
||||
@@ -772,7 +770,6 @@ impl Clone for DiskError {
|
||||
DiskError::MethodNotAllowed => DiskError::MethodNotAllowed,
|
||||
DiskError::ConditionalDeleteConflict => DiskError::ConditionalDeleteConflict,
|
||||
DiskError::GuardedDeleteUnsupported => DiskError::GuardedDeleteUnsupported,
|
||||
DiskError::DeleteRecoveryWorkLimit => DiskError::DeleteRecoveryWorkLimit,
|
||||
DiskError::ErasureWriteQuorum => DiskError::ErasureWriteQuorum,
|
||||
DiskError::ErasureReadQuorum => DiskError::ErasureReadQuorum,
|
||||
DiskError::ShortWrite => DiskError::ShortWrite,
|
||||
@@ -832,7 +829,6 @@ impl DiskError {
|
||||
DiskError::RemoteClientUnavailable(_) => 0x2B,
|
||||
DiskError::ConditionalDeleteConflict => 0x2C,
|
||||
DiskError::GuardedDeleteUnsupported => 0x2D,
|
||||
DiskError::DeleteRecoveryWorkLimit => 0x2E,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,7 +879,6 @@ impl DiskError {
|
||||
0x2B => Some(DiskError::RemoteClientUnavailable(String::new())),
|
||||
0x2C => Some(DiskError::ConditionalDeleteConflict),
|
||||
0x2D => Some(DiskError::GuardedDeleteUnsupported),
|
||||
0x2E => Some(DiskError::DeleteRecoveryWorkLimit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1667,7 +1662,6 @@ mod tests {
|
||||
for (error, code) in [
|
||||
(DiskError::ConditionalDeleteConflict, 0x2C),
|
||||
(DiskError::GuardedDeleteUnsupported, 0x2D),
|
||||
(DiskError::DeleteRecoveryWorkLimit, 0x2E),
|
||||
] {
|
||||
assert_eq!(error.to_u32(), code);
|
||||
assert_eq!(DiskError::from_u32(code), Some(error.clone()));
|
||||
|
||||
@@ -148,58 +148,47 @@ pub async fn make_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
native_io(move || create_dir_all_std(&path)).await
|
||||
}
|
||||
|
||||
fn is_dir_error(path: &Path, e: &io::Error) -> bool {
|
||||
fn is_dir_error(e: &io::Error) -> bool {
|
||||
e.raw_os_error() == Some(libc::EISDIR)
|
||||
|| e.kind() == io::ErrorKind::IsADirectory
|
||||
// macOS: remove_file on a directory returns EPERM
|
||||
|| (cfg!(target_os = "macos") && e.raw_os_error() == Some(libc::EPERM))
|
||||
|| is_windows_dir_access_denied(path, e)
|
||||
}
|
||||
|
||||
/// Windows reports `remove_file` on a directory (or directory symlink) as
|
||||
/// ERROR_ACCESS_DENIED, which is indistinguishable from a real permission
|
||||
/// failure without inspecting the entry itself.
|
||||
#[cfg(windows)]
|
||||
fn is_windows_dir_access_denied(path: &Path, e: &io::Error) -> bool {
|
||||
use std::os::windows::fs::FileTypeExt;
|
||||
|
||||
e.kind() == io::ErrorKind::PermissionDenied
|
||||
&& std::fs::symlink_metadata(path).is_ok_and(|metadata| {
|
||||
let file_type = metadata.file_type();
|
||||
file_type.is_dir() || file_type.is_symlink_dir()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_windows_dir_access_denied(_path: &Path, _e: &io::Error) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
pub async fn remove(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
native_io(move || remove_std(path)).await
|
||||
// Try remove_file first; fall back to remove_dir if it's a directory
|
||||
let file_path = path.as_ref().to_path_buf();
|
||||
match native_io(move || std::fs::remove_file(file_path)).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_dir_error(&e) => {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
native_io(move || std::fs::remove_dir(path)).await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
pub async fn remove_all(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
// Try remove_file first; fall back to remove_dir_all if it's a directory
|
||||
let path = path.as_ref().to_path_buf();
|
||||
native_io(move || match std::fs::remove_file(&path) {
|
||||
let file_path = path.as_ref().to_path_buf();
|
||||
match native_io(move || std::fs::remove_file(file_path)).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_dir_error(&path, &e) => std::fs::remove_dir_all(&path),
|
||||
Err(e) if is_dir_error(&e) => {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
native_io(move || std::fs::remove_dir_all(path)).await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
pub fn remove_std(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
// Try remove_file first; fall back to remove_dir if it's a directory
|
||||
let path = path.as_ref();
|
||||
match std::fs::remove_file(path) {
|
||||
match std::fs::remove_file(path.as_ref()) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_dir_error(path, &e) => std::fs::remove_dir(path),
|
||||
Err(e) if is_dir_error(&e) => std::fs::remove_dir(path.as_ref()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ pub(super) mod delete_recovery;
|
||||
#[cfg(all(unix, test))]
|
||||
#[path = "fd_cache_tests.rs"]
|
||||
mod fd_cache_tests;
|
||||
mod multipart_index;
|
||||
pub(in crate::disk) mod multipart_source_abort;
|
||||
mod multipart_source_recovery;
|
||||
pub(in crate::disk) mod multipart_source_state;
|
||||
@@ -185,13 +184,6 @@ const DELETE_RESIDUE_PROBE_LIMIT: i32 = 8;
|
||||
/// large residue tree, which then hides one level at a time instead.
|
||||
const DELETE_RESIDUE_PROBE_READ_BUDGET: usize = 32;
|
||||
|
||||
/// What one bounded directory read found under a prefix.
|
||||
enum PrefixListingProbe {
|
||||
Visible,
|
||||
Empty,
|
||||
Cut(Vec<MetaCacheEntry>),
|
||||
}
|
||||
|
||||
/// One directory read owed by the delete-residue probe.
|
||||
enum ResidueProbeStep {
|
||||
/// Read a bounded batch of `dir` and probe what it holds.
|
||||
@@ -652,7 +644,6 @@ async fn write_delete_rollback_files(
|
||||
let object_dir_buf = object_dir.to_path_buf();
|
||||
let volume_str = volume.to_owned();
|
||||
let inspect_object = inspect_object.map(str::to_owned);
|
||||
let recovery_scope = delete_recovery::recovery_operation_scope();
|
||||
os::run_blocking_namespace_operation_with_sync(first_lease, sync, move || {
|
||||
for (path, _) in &removed_entries {
|
||||
reject_local_disk_symlink_components(&io_root, path).map_err(std::io::Error::from)?;
|
||||
@@ -711,21 +702,10 @@ async fn write_delete_rollback_files(
|
||||
}
|
||||
}
|
||||
drop(staging_dir);
|
||||
if entries.iter().any(|(name, _, _, _)| *name == DELETE_QUORUM_COMMITTED_FILE)
|
||||
&& let Some(scope) = recovery_scope.as_ref()
|
||||
{
|
||||
scope.receipt_published();
|
||||
}
|
||||
let inspected = inspect_object
|
||||
.map(|object| {
|
||||
delete_recovery::inspection_with_markers_bounded(
|
||||
&object_dir_buf,
|
||||
&volume_str,
|
||||
&object,
|
||||
rollback_dir,
|
||||
recovery_scope.as_ref().map(|scope| scope.budget.as_ref()),
|
||||
)
|
||||
.map(|(state, _)| state)
|
||||
delete_recovery::inspection_with_markers(&object_dir_buf, &volume_str, &object, rollback_dir)
|
||||
.map(|(state, _)| state)
|
||||
})
|
||||
.transpose();
|
||||
drop((removed_entries, entries, inspect_lease));
|
||||
@@ -797,10 +777,7 @@ async fn restore_delete_rollback_with_namespace_owner(
|
||||
let mut remove_new_metadata = false;
|
||||
match fs::read_dir(&rollback_path).await {
|
||||
Ok(mut entries) => {
|
||||
while let Some(entry) = delete_recovery::next_recovery_entry(&mut entries)
|
||||
.await
|
||||
.map_err(to_file_error)?
|
||||
{
|
||||
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
|
||||
let name = entry.file_name();
|
||||
if name == DELETE_MARKER_ROLLBACK_FILE {
|
||||
remove_new_metadata = true;
|
||||
@@ -834,10 +811,7 @@ async fn restore_delete_rollback_with_namespace_owner(
|
||||
if recovery_journal.is_some() {
|
||||
let mut entries = fs::read_dir(&src).await.map_err(to_file_error)?;
|
||||
let mut files = Vec::new();
|
||||
while let Some(entry) = delete_recovery::next_recovery_entry(&mut entries)
|
||||
.await
|
||||
.map_err(to_file_error)?
|
||||
{
|
||||
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_str().ok_or(DiskError::FileCorrupt)?;
|
||||
if !entry.file_type().await.map_err(to_file_error)?.is_file()
|
||||
@@ -935,16 +909,10 @@ async fn restore_delete_rollback_with_namespace_owner(
|
||||
match restored {
|
||||
Ok(()) => {}
|
||||
Err(DiskError::FileNotFound) if remove_new_metadata => {
|
||||
let preserves_child_prefix = recovery_journal
|
||||
.as_ref()
|
||||
.is_some_and(delete_recovery::missing_marker_preserves_child_prefix)
|
||||
&& fs::symlink_metadata(xl_path).await.is_ok_and(|metadata| metadata.is_dir());
|
||||
if !preserves_child_prefix {
|
||||
match os::remove_file_with_owner(xl_path, namespace_owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
match os::remove_file_with_owner(xl_path, namespace_owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
}
|
||||
Err(DiskError::FileNotFound) if had_staged_paths => return Err(DiskError::FileNotFound),
|
||||
@@ -994,10 +962,7 @@ async fn remove_version_delete_markers(
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
};
|
||||
while let Some(entry) = delete_recovery::next_recovery_entry(&mut entries)
|
||||
.await
|
||||
.map_err(to_file_error)?
|
||||
{
|
||||
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
|
||||
if !entry.file_type().await.map_err(to_file_error)?.is_dir()
|
||||
|| !entry.file_name().to_str().is_some_and(|name| Uuid::parse_str(name).is_ok())
|
||||
{
|
||||
@@ -7511,7 +7476,6 @@ pub struct LocalDisk {
|
||||
pub root: PathBuf,
|
||||
publication_root: os::PublicationRoot,
|
||||
multipart_proof_authority: OnceLock<crate::disk::multipart_source::ProofVerificationContext>,
|
||||
multipart_index: Arc<multipart_index::IndexOwner>,
|
||||
/// I/O root pinned to the mount instance that was opened while the disk
|
||||
/// was initialized. On Linux this is `/proc/self/fd/<dirfd>/.`; resolving
|
||||
/// paths beneath it keeps repair I/O on that mount even if the configured
|
||||
@@ -8054,10 +8018,7 @@ impl LocalDisk {
|
||||
|
||||
// TODO(backlog): add O_DIRECT I/O support for performance-critical paths
|
||||
// TODO(backlog): populate DiskInfo in constructor
|
||||
let multipart_index = multipart_index::IndexOwner::acquire(&publication_root, &root, &io_root)?;
|
||||
let blocking_queue = multipart_index.attach_blocking_queue(&root, &io_root);
|
||||
let mut disk = Self {
|
||||
multipart_index,
|
||||
root: root.clone(),
|
||||
publication_root,
|
||||
multipart_proof_authority: OnceLock::new(),
|
||||
@@ -8093,7 +8054,7 @@ impl LocalDisk {
|
||||
)
|
||||
.await,
|
||||
file_sync_permits: os::disk_file_sync_limiter(&root),
|
||||
blocking_queue,
|
||||
blocking_queue: os::register_disk_blocking_queue(&root, &io_root),
|
||||
snapshot_leases: Arc::new(ParkingLotMutex::new(SnapshotLeaseRegistry::default())),
|
||||
#[cfg(unix)]
|
||||
pool_meta_durable_head: ParkingLotMutex::new(None),
|
||||
@@ -8146,7 +8107,6 @@ impl LocalDisk {
|
||||
publication_root,
|
||||
#[cfg(target_os = "linux")]
|
||||
disk.mount_lease.clone(),
|
||||
disk.multipart_index.clone(),
|
||||
exit_rx,
|
||||
));
|
||||
debug!(
|
||||
@@ -8165,19 +8125,8 @@ impl LocalDisk {
|
||||
root: PathBuf,
|
||||
publication_root: os::PublicationRoot,
|
||||
#[cfg(target_os = "linux")] mount_lease: Arc<std::fs::File>,
|
||||
multipart_index: Arc<multipart_index::IndexOwner>,
|
||||
mut exit_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) {
|
||||
// The multipart index bootstrap rides this task instead of its own:
|
||||
// the disk lifecycle accounts for exactly this background task, and
|
||||
// stopping the disk abandons the scan (a reconnect resumes it).
|
||||
if let Some(bootstrap) = multipart_index.start() {
|
||||
tokio::select! {
|
||||
_ = bootstrap => {}
|
||||
_ = exit_rx.recv() => return,
|
||||
}
|
||||
}
|
||||
drop(multipart_index);
|
||||
let start_at = Instant::now() + DELETED_OBJECTS_CLEANUP_INTERVAL;
|
||||
let mut interval = interval_at(start_at, DELETED_OBJECTS_CLEANUP_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
@@ -9960,10 +9909,7 @@ impl LocalDisk {
|
||||
let mut transaction_cleanup = false;
|
||||
match fs::read_dir(&transaction_path).await {
|
||||
Ok(mut entries) => {
|
||||
while let Some(entry) = delete_recovery::next_recovery_entry(&mut entries)
|
||||
.await
|
||||
.map_err(to_file_error)?
|
||||
{
|
||||
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
|
||||
let name = entry.file_name();
|
||||
if !entry.file_type().await.map_err(to_file_error)?.is_file()
|
||||
|| (name != STORAGE_FORMAT_FILE_BACKUP
|
||||
@@ -9992,7 +9938,7 @@ impl LocalDisk {
|
||||
if !std::fs::symlink_metadata(&receipt_path)?.is_file() {
|
||||
return Err(std::io::Error::new(ErrorKind::InvalidData, "delete receipt is not a regular file"));
|
||||
}
|
||||
os::open_file_for_sync_std(&receipt_path)?
|
||||
std::fs::File::open(receipt_path)?
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
@@ -10179,7 +10125,6 @@ impl LocalDisk {
|
||||
let data_path = file_path.clone();
|
||||
let sync = effective_durability(volume).syncs_commit_metadata();
|
||||
let keep_rollback_witnesses = opt.recoverable_delete;
|
||||
let recovery_scope = delete_recovery::recovery_operation_scope();
|
||||
os::run_blocking_namespace_operation_with_sync(lease, sync, move || {
|
||||
if let Some(check) = &multipart_check {
|
||||
check.callback()()?;
|
||||
@@ -10197,9 +10142,7 @@ impl LocalDisk {
|
||||
let mut parts = Vec::new();
|
||||
let mut markers = Vec::new();
|
||||
let mut has_committed_marker = false;
|
||||
for entry in
|
||||
delete_recovery::recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref()))
|
||||
{
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_str().ok_or_else(corrupt)?;
|
||||
@@ -10557,6 +10500,7 @@ impl LocalDisk {
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn read_listing_metadata(&self, volume: &str, object_name: &str) -> Result<ListingMetadataRead> {
|
||||
let mut batch = self
|
||||
.spawn_listing_metadata_batch(volume, vec![object_name.to_owned()], None)
|
||||
@@ -10703,14 +10647,6 @@ impl LocalDisk {
|
||||
let rollback_dir = opts.old_data_dir;
|
||||
let incarnation = opts.delete_recovery_bucket_incarnation;
|
||||
let xl_path = object_dir.join(STORAGE_FORMAT_FILE);
|
||||
// An independent child key owns this directory. Reject before creating
|
||||
// recovery evidence that could otherwise make a harmless prefix unhealable.
|
||||
match fs::symlink_metadata(&xl_path).await {
|
||||
Ok(metadata) if metadata.is_dir() => return Err(DiskError::IsNotRegular),
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
let mut recovery_target = None;
|
||||
if let Some(rollback_dir) = rollback_dir {
|
||||
if opts.recoverable_delete {
|
||||
@@ -11415,9 +11351,6 @@ impl LocalDisk {
|
||||
admission: Option<multipart_staging::MultipartSourceAdmission>,
|
||||
) -> Result<()> {
|
||||
multipart_staging::reject_reserved_anchor(path)?;
|
||||
if volume == RUSTFS_META_MULTIPART_BUCKET {
|
||||
self.multipart_index.record_head(path, &buf).await?;
|
||||
}
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
@@ -11838,9 +11771,9 @@ impl LocalDisk {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = with_walk_stall_timeout(stall, self.read_listing_metadata(bucket, ¤t))
|
||||
.await?
|
||||
.bytes;
|
||||
let metadata =
|
||||
with_walk_stall_timeout(stall, self.read_metadata(bucket, format!("{}/{}", current, entry).as_str()))
|
||||
.await?;
|
||||
|
||||
let entry = entry.strip_suffix(STORAGE_FORMAT_FILE).unwrap_or_default().to_owned();
|
||||
let name = entry.trim_end_matches(SLASH_SEPARATOR);
|
||||
@@ -11884,27 +11817,19 @@ impl LocalDisk {
|
||||
});
|
||||
}
|
||||
|
||||
type PendingDirectory = (String, bool, Option<HashSet<String>>, bool, bool);
|
||||
let mut dir_stack: Vec<PendingDirectory> = Vec::with_capacity(5);
|
||||
let mut dir_stack: Vec<(String, bool, Option<HashSet<String>>, bool)> = Vec::with_capacity(5);
|
||||
// Explicit directory markers and real directories can resolve to the same logical path.
|
||||
let schedule_dir = |dir_stack: &mut Vec<PendingDirectory>,
|
||||
let schedule_dir = |dir_stack: &mut Vec<(String, bool, Option<HashSet<String>>, bool)>,
|
||||
dir_name: String,
|
||||
skip_object: bool,
|
||||
dir_to_skip: Option<HashSet<String>>,
|
||||
scan_required: bool,
|
||||
verify_prefix: bool| {
|
||||
if let Some((
|
||||
last_dir_name,
|
||||
existing_skip_object,
|
||||
existing_dir_to_skip,
|
||||
existing_scan_required,
|
||||
existing_verify_prefix,
|
||||
)) = dir_stack.last_mut()
|
||||
scan_required: bool| {
|
||||
if let Some((last_dir_name, existing_skip_object, existing_dir_to_skip, existing_scan_required)) =
|
||||
dir_stack.last_mut()
|
||||
&& *last_dir_name == dir_name
|
||||
{
|
||||
*existing_skip_object |= skip_object;
|
||||
*existing_scan_required |= scan_required;
|
||||
*existing_verify_prefix |= verify_prefix;
|
||||
if let Some(existing_dir_to_skip) = existing_dir_to_skip {
|
||||
if let Some(new_dir_to_skip) = &dir_to_skip {
|
||||
existing_dir_to_skip.extend(new_dir_to_skip.iter().cloned());
|
||||
@@ -11913,12 +11838,9 @@ impl LocalDisk {
|
||||
*existing_dir_to_skip = dir_to_skip;
|
||||
}
|
||||
} else {
|
||||
dir_stack.push((dir_name, skip_object, dir_to_skip, scan_required, verify_prefix));
|
||||
dir_stack.push((dir_name, skip_object, dir_to_skip, scan_required));
|
||||
}
|
||||
};
|
||||
let bounded_probe =
|
||||
!opts.recursive && !opts.incl_deleted && !opts.skip_hidden_prefix_check && opts.hidden_entry_budget > 0;
|
||||
let mut remaining_hidden = opts.hidden_entry_budget;
|
||||
prefix = "".to_owned();
|
||||
let mut prefetched_batch: Option<ListingMetadataBatch> = None;
|
||||
let mut adaptive_batch_cap = WALK_DIR_BATCH_SIZE;
|
||||
@@ -11934,7 +11856,7 @@ impl LocalDisk {
|
||||
|
||||
let name = path_join_buf(&[current.as_str(), entry.as_str()]);
|
||||
|
||||
while let Some((last_name, _, _, _, _)) = dir_stack.last()
|
||||
while let Some((last_name, _, _, _)) = dir_stack.last()
|
||||
&& *last_name < name
|
||||
{
|
||||
// A prior iteration of this same loop may have just recursed
|
||||
@@ -11948,20 +11870,7 @@ impl LocalDisk {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let (pop, skip_object, dir_to_skip, scan_required, verify_prefix) =
|
||||
dir_stack.pop().expect("operation should succeed");
|
||||
if verify_prefix {
|
||||
match self.probe_listing_prefix(opts, &pop, &mut remaining_hidden).await? {
|
||||
PrefixListingProbe::Empty => continue,
|
||||
PrefixListingProbe::Visible => {}
|
||||
PrefixListingProbe::Cut(entries) => {
|
||||
for entry in entries {
|
||||
write_metacache_obj(out, &entry).await?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (pop, skip_object, dir_to_skip, scan_required) = dir_stack.pop().expect("operation should succeed");
|
||||
let more_ready_in_batch = !(opts.recursive && scan_required)
|
||||
&& prefetched_batch.as_ref().is_some_and(|b| b.remaining > 0 && !b.rx.is_empty());
|
||||
if more_ready_in_batch {
|
||||
@@ -12069,7 +11978,7 @@ impl LocalDisk {
|
||||
};
|
||||
let batch_names = if batch_limit > 1
|
||||
&& dir_objes.is_empty()
|
||||
&& dir_stack.iter().all(|(_, _, _, scan_required, _)| !*scan_required)
|
||||
&& dir_stack.iter().all(|(_, _, _, scan_required)| !*scan_required)
|
||||
{
|
||||
entries[entry_idx..]
|
||||
.iter()
|
||||
@@ -12092,7 +12001,23 @@ impl LocalDisk {
|
||||
}
|
||||
.await
|
||||
} else {
|
||||
with_walk_stall_timeout(stall, self.read_listing_metadata(&opts.bucket, &meta.name)).await
|
||||
let fname = format!("{}/{}", meta.name, STORAGE_FORMAT_FILE);
|
||||
with_walk_stall_timeout(stall, self.read_metadata(&opts.bucket, fname.as_str()))
|
||||
.await
|
||||
.map(|metadata| {
|
||||
let bytes = Vec::from(metadata);
|
||||
let (file_meta, data_dirs, counts_toward_limit) = parse_listing_metadata_bytes(&bytes);
|
||||
#[cfg(not(test))]
|
||||
let _ = file_meta;
|
||||
ListingMetadataRead {
|
||||
bytes,
|
||||
#[cfg(test)]
|
||||
file_meta,
|
||||
data_dirs,
|
||||
has_namespace_child_candidate: true,
|
||||
counts_toward_limit,
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
match metadata_read {
|
||||
@@ -12138,20 +12063,12 @@ impl LocalDisk {
|
||||
true,
|
||||
if dir_to_skip.is_empty() { None } else { Some(dir_to_skip) },
|
||||
has_namespace_child_candidate,
|
||||
false,
|
||||
);
|
||||
} else if !is_dir_obj
|
||||
&& has_namespace_child_candidate
|
||||
&& (bounded_probe
|
||||
|| self
|
||||
.object_dir_has_listable_child(
|
||||
&opts.bucket,
|
||||
&meta.name,
|
||||
&dir_to_skip,
|
||||
opts.incl_deleted,
|
||||
stall,
|
||||
)
|
||||
.await?)
|
||||
&& self
|
||||
.object_dir_has_listable_child(&opts.bucket, &meta.name, &dir_to_skip, opts.incl_deleted, stall)
|
||||
.await?
|
||||
{
|
||||
// A plain object `a` shares its backing directory with any
|
||||
// children `a/...`, and non-recursive walks never descend into
|
||||
@@ -12161,7 +12078,7 @@ impl LocalDisk {
|
||||
// separate real directory entry handled above.
|
||||
let mut dir_name = meta.name.clone();
|
||||
dir_name.push_str(SLASH_SEPARATOR);
|
||||
schedule_dir(&mut dir_stack, dir_name, true, None, true, bounded_probe);
|
||||
schedule_dir(&mut dir_stack, dir_name, true, None, true);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -12177,7 +12094,7 @@ impl LocalDisk {
|
||||
meta.name.push_str(SLASH_SEPARATOR);
|
||||
// Conservative listings verify physical prefixes. Never-versioned
|
||||
// buckets first rule out the data dirs a deleted version leaves behind.
|
||||
let listable = if opts.recursive || opts.incl_deleted || bounded_probe {
|
||||
let listable = if opts.recursive || opts.incl_deleted {
|
||||
true
|
||||
} else if opts.skip_hidden_prefix_check {
|
||||
!self.directory_is_delete_residue(&opts.bucket, &meta.name, stall).await?
|
||||
@@ -12186,7 +12103,7 @@ impl LocalDisk {
|
||||
.await?
|
||||
};
|
||||
if listable {
|
||||
schedule_dir(&mut dir_stack, meta.name, false, None, true, bounded_probe);
|
||||
schedule_dir(&mut dir_stack, meta.name, false, None, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12208,23 +12125,11 @@ impl LocalDisk {
|
||||
};
|
||||
}
|
||||
|
||||
while let Some((dir, skip_object, dir_to_skip, scan_required, verify_prefix)) = dir_stack.pop() {
|
||||
while let Some((dir, skip_object, dir_to_skip, scan_required)) = dir_stack.pop() {
|
||||
if opts.limit > 0 && *objs_returned >= opts.limit {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if verify_prefix {
|
||||
match self.probe_listing_prefix(opts, &dir, &mut remaining_hidden).await? {
|
||||
PrefixListingProbe::Empty => continue,
|
||||
PrefixListingProbe::Visible => {}
|
||||
PrefixListingProbe::Cut(entries) => {
|
||||
for entry in entries {
|
||||
write_metacache_obj(out, &entry).await?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
write_metacache_obj(
|
||||
out,
|
||||
&MetaCacheEntry {
|
||||
@@ -12442,86 +12347,6 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe a prefix in the same byte order as the listing stream. Keep the
|
||||
/// ordinary one-prefix result when a live object is found; only publish
|
||||
/// hidden entries when their budget forces a resumable stream cut.
|
||||
async fn probe_listing_prefix(
|
||||
&self,
|
||||
opts: &WalkDirOptions,
|
||||
prefix: &str,
|
||||
remaining: &mut usize,
|
||||
) -> Result<PrefixListingProbe> {
|
||||
if opts.forward_to.as_ref().is_some_and(|marker| prefix <= marker.as_str())
|
||||
&& opts.pending_prefix.as_deref() != Some(prefix)
|
||||
{
|
||||
return Ok(PrefixListingProbe::Empty);
|
||||
}
|
||||
let root = prefix.trim_end_matches('/').to_owned();
|
||||
let mut pending = std::collections::BTreeMap::from([(root.clone(), root.clone())]);
|
||||
let mut hidden = Vec::new();
|
||||
let mut bytes = 0usize;
|
||||
let stall = opts.stall_timeout_duration();
|
||||
while let Some((name, path)) = pending.pop_first() {
|
||||
#[cfg(test)]
|
||||
let _previous_probe_count =
|
||||
DIRECTORY_LISTING_ENTRY_PROBE_COUNT.try_with(|count| count.fetch_add(1, Ordering::Relaxed));
|
||||
let mut data_dirs = HashSet::new();
|
||||
match with_walk_stall_timeout(stall, self.read_listing_metadata(&opts.bucket, &path)).await {
|
||||
Ok(read) => {
|
||||
data_dirs.extend(read.data_dirs.into_iter().map(|id| id.to_string()));
|
||||
// The root object's visibility does not prove that its
|
||||
// child prefix exists. Ancestor metadata still identifies
|
||||
// internal data directories that must never be traversed.
|
||||
if path != root && opts.forward_to.as_ref().is_none_or(|marker| &name > marker) {
|
||||
if read.counts_toward_limit {
|
||||
return Ok(PrefixListingProbe::Visible);
|
||||
}
|
||||
bytes = bytes.saturating_add(read.bytes.len());
|
||||
hidden.push(MetaCacheEntry {
|
||||
name: name.clone(),
|
||||
metadata: read.bytes,
|
||||
..Default::default()
|
||||
});
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
if *remaining == 0 || bytes >= LISTING_META_BATCH_MAX_BYTES {
|
||||
return Ok(PrefixListingProbe::Cut(hidden));
|
||||
}
|
||||
}
|
||||
if !read.has_namespace_child_candidate {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(Error::FileNotFound | Error::IsNotRegular) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
let children = match with_walk_stall_timeout(stall, self.list_dir("", &opts.bucket, &path, -1)).await {
|
||||
Ok(children) => children,
|
||||
Err(Error::FileNotFound | Error::VolumeNotFound) => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
for child in children {
|
||||
let Some(child) = child.strip_suffix('/') else { continue };
|
||||
if child.is_empty() || data_dirs.contains(child) {
|
||||
continue;
|
||||
}
|
||||
let path = path_join_buf(&[&path, child]);
|
||||
let name = decode_dir_object(&path);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if opts
|
||||
.forward_to
|
||||
.as_ref()
|
||||
.is_some_and(|marker| &name <= marker && !marker.starts_with(&format!("{name}/")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
pending.insert(name, path);
|
||||
}
|
||||
}
|
||||
Ok(PrefixListingProbe::Empty)
|
||||
}
|
||||
|
||||
/// Whether anything under `dir_name` would appear in a listing. With
|
||||
/// `incl_deleted`, any `xl.meta` counts (versioned listings surface
|
||||
/// delete-marker-only objects too); otherwise the metadata must hold a
|
||||
@@ -12564,9 +12389,10 @@ impl LocalDisk {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
match with_walk_stall_timeout(stall, self.read_listing_metadata(bucket, ¤t)).await {
|
||||
let metadata_path = path_join_buf(&[current.as_str(), STORAGE_FORMAT_FILE]);
|
||||
match with_walk_stall_timeout(stall, self.read_metadata(bucket, metadata_path.as_str())).await {
|
||||
Ok(metadata) => {
|
||||
let file_meta = match FileMeta::load(&metadata.bytes) {
|
||||
let file_meta = match FileMeta::load(&metadata) {
|
||||
Ok(file_meta) => file_meta,
|
||||
Err(_) => return Ok(true),
|
||||
};
|
||||
@@ -13150,7 +12976,6 @@ impl LocalDisk {
|
||||
let reserved_name = format!("{RESERVED_DELETE_DATA_DIR_MARKER_PREFIX}{rollback_dir}");
|
||||
let scan_object_path = object_path.clone();
|
||||
let scan_reserved_name = reserved_name.clone();
|
||||
let recovery_scope = delete_recovery::recovery_operation_scope();
|
||||
let (matching_dirs, recovery_owned) = self
|
||||
.blocking_queue
|
||||
.run_cancellable(move |is_cancelled| {
|
||||
@@ -13160,10 +12985,7 @@ impl LocalDisk {
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let mut matching = Vec::new();
|
||||
for (idx, entry) in
|
||||
delete_recovery::recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref()))
|
||||
.enumerate()
|
||||
{
|
||||
for (idx, entry) in entries.enumerate() {
|
||||
if idx & 63 == 0 && is_cancelled() {
|
||||
return Err(std::io::Error::other("blocking namespace operation cancelled"));
|
||||
}
|
||||
@@ -13465,7 +13287,6 @@ impl LocalDisk {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let recovery_scope = delete_recovery::recovery_operation_scope();
|
||||
let matching_data_dirs = self
|
||||
.blocking_queue
|
||||
.run_cancellable(move |is_cancelled| {
|
||||
@@ -13485,10 +13306,7 @@ impl LocalDisk {
|
||||
};
|
||||
let marker_name = format!("{prefix}{rollback_dir}");
|
||||
let mut matched = Vec::new();
|
||||
for (idx, entry) in
|
||||
delete_recovery::recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref()))
|
||||
.enumerate()
|
||||
{
|
||||
for (idx, entry) in entries.enumerate() {
|
||||
if idx & 63 == 0 && is_cancelled() {
|
||||
return Err(std::io::Error::other("blocking namespace operation cancelled"));
|
||||
}
|
||||
@@ -13753,14 +13571,6 @@ fn measure_local_batch_stage<T>(
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for LocalDisk {
|
||||
async fn list_multipart_index_roots(
|
||||
&self,
|
||||
bucket: &str,
|
||||
marker: Option<&str>,
|
||||
) -> Result<crate::disk::multipart_index::MultipartIndexPage> {
|
||||
self.multipart_index_page(bucket, marker).await
|
||||
}
|
||||
|
||||
async fn supports_guarded_delete_versions(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -13806,14 +13616,6 @@ impl DiskAPI for LocalDisk {
|
||||
async fn has_delete_transaction_evidence(&self, volume: &str, object: &str) -> Result<bool> {
|
||||
self.delete_recovery_has_evidence(volume, object).await
|
||||
}
|
||||
async fn discover_delete_transactions_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
max_entries: u32,
|
||||
) -> Result<super::BoundedDeleteRecoveryDiscovery> {
|
||||
self.delete_recovery_discover_bounded(volume, object, max_entries).await
|
||||
}
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -13823,24 +13625,6 @@ impl DiskAPI for LocalDisk {
|
||||
) -> Result<super::DeleteRecoveryPage> {
|
||||
self.delete_recovery_discover(volume, object, cursor, limit).await
|
||||
}
|
||||
async fn settle_delete_transaction_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
request: super::BoundedDeleteRecoverySettlement,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<super::BoundedDeleteRecoverySettlementReply> {
|
||||
self.delete_recovery_settle_bounded(volume, request, namespace_owner).await
|
||||
}
|
||||
async fn inspect_delete_transaction_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
max_entries: u32,
|
||||
) -> Result<super::BoundedDeleteRecoveryInspection> {
|
||||
self.delete_recovery_inspect_bounded(volume, object, transaction, max_entries)
|
||||
.await
|
||||
}
|
||||
async fn inspect_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -14513,7 +14297,7 @@ impl DiskAPI for LocalDisk {
|
||||
return Ok(Err(DiskError::FileCorrupt));
|
||||
}
|
||||
let verify_and_sync = (|| -> std::io::Result<bool> {
|
||||
let mut file = os::open_file_for_sync_std(&source)?;
|
||||
let mut file = std::fs::File::open(&source)?;
|
||||
let mut header = [0u8; rustfs_filemeta::shard_integrity::INDEX_HEADER_SIZE];
|
||||
std::io::Read::read_exact(&mut file, &mut header)?;
|
||||
if header != integrity.index_header() {
|
||||
@@ -15306,16 +15090,20 @@ impl DiskAPI for LocalDisk {
|
||||
if opts.base_dir.ends_with(SLASH_SEPARATOR) {
|
||||
if let Ok(data) = with_walk_stall_timeout(
|
||||
stall,
|
||||
self.read_listing_metadata(
|
||||
self.read_metadata(
|
||||
&opts.bucket,
|
||||
&format!("{}{}", opts.base_dir.trim_end_matches(SLASH_SEPARATOR), GLOBAL_DIR_SUFFIX),
|
||||
path_join_buf(&[
|
||||
format!("{}{}", opts.base_dir.trim_end_matches(SLASH_SEPARATOR), GLOBAL_DIR_SUFFIX).as_str(),
|
||||
STORAGE_FORMAT_FILE,
|
||||
])
|
||||
.as_str(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let meta = MetaCacheEntry {
|
||||
name: opts.base_dir.clone(),
|
||||
metadata: data.bytes,
|
||||
metadata: data.to_vec(),
|
||||
..Default::default()
|
||||
};
|
||||
write_metacache_obj(&mut out, &meta).await?;
|
||||
@@ -15333,9 +15121,15 @@ impl DiskAPI for LocalDisk {
|
||||
} && meta.is_file()
|
||||
{
|
||||
skip_current_dir_object = true;
|
||||
if let Ok(meta_bytes) =
|
||||
with_walk_stall_deadline(stall, self.read_listing_metadata(&opts.bucket, &opts.base_dir)).await?
|
||||
&& let Ok(file_meta) = FileMeta::load(&meta_bytes.bytes)
|
||||
if let Ok(meta_bytes) = with_walk_stall_deadline(
|
||||
stall,
|
||||
self.read_metadata(
|
||||
opts.bucket.as_str(),
|
||||
path_join_buf(&[opts.base_dir.as_str(), STORAGE_FORMAT_FILE]).as_str(),
|
||||
),
|
||||
)
|
||||
.await?
|
||||
&& let Ok(file_meta) = FileMeta::load(&meta_bytes)
|
||||
&& let Ok(data_dirs) = file_meta.get_data_dirs()
|
||||
{
|
||||
for data_dir in data_dirs.iter().flatten() {
|
||||
@@ -15692,10 +15486,7 @@ impl DiskAPI for LocalDisk {
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
|
||||
let xl_rel_path = format!("{path}/{STORAGE_FORMAT_FILE}");
|
||||
// The common publisher indexes the merged owner metadata before
|
||||
// a multipart head becomes durable, including legacy source heads.
|
||||
if volume != RUSTFS_META_MULTIPART_BUCKET
|
||||
&& self.multipart_source_admission(volume, &xl_rel_path, true, false)?.is_none()
|
||||
if self.multipart_source_admission(volume, &xl_rel_path, true, false)?.is_none()
|
||||
&& !crash_inject::is_armed(CrashPoint::MetaWriteAfterTmpBeforeRename, &xl_rel_path)
|
||||
&& !has_delete_version_test_failpoints()
|
||||
&& !has_rename_data_test_failpoints(&xl_rel_path)
|
||||
@@ -19754,9 +19545,6 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
// Only the Unix grouped dst dir fsync worker consults the injected
|
||||
// failure; the non-Unix worker syncs by path without the hook.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(dst_dir_fsync_group_commit)]
|
||||
async fn rename_data_non_inline_dst_dir_fsync_group_commit_failure_rolls_back_fresh_put() {
|
||||
@@ -19845,9 +19633,6 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
// Only the Unix grouped dst dir fsync worker consults the injected
|
||||
// failure; the non-Unix worker syncs by path without the hook.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(dst_dir_fsync_group_commit)]
|
||||
async fn rename_data_inline_dst_dir_fsync_group_commit_failure_rolls_back_fresh_put() {
|
||||
@@ -26582,7 +26367,6 @@ mod test {
|
||||
root.clone(),
|
||||
disk.publication_root.clone(),
|
||||
disk.mount_lease.clone(),
|
||||
disk.multipart_index.clone(),
|
||||
exited,
|
||||
));
|
||||
tokio::task::yield_now().await;
|
||||
@@ -26879,162 +26663,6 @@ mod test {
|
||||
assert!(FileMeta::load(&corrupt_read.bytes).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_prefix_probe_resumes_hidden_subtrees_and_keeps_live_prefix_fast_path() {
|
||||
use rustfs_filemeta::MetacacheReader;
|
||||
let directory = tempfile::tempdir().expect("disk");
|
||||
let bucket = "bounded-prefix-probe";
|
||||
let modified = OffsetDateTime::from_unix_timestamp(1_705_312_500).expect("time");
|
||||
for index in 0..600 {
|
||||
let name = format!("prefix/{index:04}");
|
||||
let mut info = if index < 250 {
|
||||
FileInfo {
|
||||
name: name.clone(),
|
||||
deleted: true,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
FileInfo::new(&name, 1, 1)
|
||||
};
|
||||
info.version_id = Some(Uuid::new_v4());
|
||||
info.mod_time = Some(modified);
|
||||
let mut metadata = FileMeta::default();
|
||||
metadata.add_version(info).expect("version");
|
||||
assert_eq!(file_meta_counts_toward_limit(&metadata), index >= 250);
|
||||
let path = directory.path().join(bucket).join(&name);
|
||||
fs::create_dir_all(&path).await.expect("object directory");
|
||||
fs::write(path.join(STORAGE_FORMAT_FILE), metadata.marshal_msg().expect("metadata"))
|
||||
.await
|
||||
.expect("object metadata");
|
||||
}
|
||||
let endpoint = Endpoint::try_from(directory.path().to_str().expect("path")).expect("endpoint");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("disk");
|
||||
let mut marker = None;
|
||||
for page in 0..3 {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let (reader, mut writer) = tokio::io::duplex(1024 * 1024);
|
||||
DIRECTORY_LISTING_ENTRY_PROBE_COUNT
|
||||
.scope(
|
||||
count.clone(),
|
||||
disk.walk_dir(
|
||||
WalkDirOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
recursive: false,
|
||||
hidden_entry_budget: 100,
|
||||
forward_to: marker.clone(),
|
||||
pending_prefix: marker.as_ref().map(|_| "prefix/".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
&mut writer,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("bounded walk");
|
||||
drop(writer);
|
||||
let mut reader = MetacacheReader::new(reader);
|
||||
let mut entries = reader.read_all().await.expect("stream");
|
||||
assert!(count.load(Ordering::Relaxed) <= 102, "one page must not read the entire hidden subtree");
|
||||
if page < 2 {
|
||||
assert_eq!(entries.len(), 100);
|
||||
assert_eq!(reader.limit_reached(), Some(true));
|
||||
assert!(entries.iter_mut().all(|entry| entry.is_latest_delete_marker()));
|
||||
marker = entries.last().map(|entry| entry.name.clone());
|
||||
assert_eq!(marker, Some(format!("prefix/{:04}", (page + 1) * 100 - 1)));
|
||||
} else {
|
||||
assert_eq!(entries.iter().map(|entry| entry.name.as_str()).collect::<Vec<_>>(), ["prefix/"]);
|
||||
assert_eq!(reader.limit_reached(), Some(false));
|
||||
assert!(
|
||||
count.load(Ordering::Relaxed) < 60,
|
||||
"a live witness must keep the whole remaining subtree out of the probe"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_strips_inline_bodies_from_base_objects_and_directory_markers() {
|
||||
use rustfs_filemeta::MetacacheReader;
|
||||
let directory = tempfile::tempdir().expect("disk directory");
|
||||
let bucket = "inline-listing";
|
||||
let root = directory.path().join(bucket);
|
||||
let mut expected = std::collections::HashMap::new();
|
||||
for name in ["parent", "parent/child", "folder/", "folder/child"] {
|
||||
let object_dir = root.join(encode_dir_object(name));
|
||||
fs::create_dir_all(&object_dir).await.expect("object directory");
|
||||
let mut info = FileInfo::new(name, 1, 1);
|
||||
info.size = 128 * 1024;
|
||||
info.data_dir = Some(Uuid::new_v4());
|
||||
info.mod_time = Some(OffsetDateTime::now_utc());
|
||||
info.data = Some(Bytes::from(vec![42; 128 * 1024]));
|
||||
info.set_inline_data();
|
||||
let mut meta = FileMeta::default();
|
||||
meta.add_version(info).expect("inline metadata");
|
||||
let full = meta.marshal_msg().expect("serialize metadata");
|
||||
let envelope = read_xl_meta_no_data_sync(&mut std::io::Cursor::new(&full), full.len()).expect("metadata envelope");
|
||||
assert!(full.len() > envelope.len() + 120 * 1024);
|
||||
expected.insert(name.to_string(), envelope);
|
||||
fs::write(object_dir.join(STORAGE_FORMAT_FILE), full)
|
||||
.await
|
||||
.expect("inline xl.meta");
|
||||
}
|
||||
let endpoint = Endpoint::try_from(directory.path().to_str().unwrap()).unwrap();
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
|
||||
for (base, recursive) in [
|
||||
("", true),
|
||||
("", false),
|
||||
("parent", true),
|
||||
("parent/", true),
|
||||
("folder/", true),
|
||||
] {
|
||||
let (reader, mut writer) = tokio::io::duplex(1024 * 1024);
|
||||
disk.walk_dir(
|
||||
WalkDirOptions {
|
||||
bucket: bucket.into(),
|
||||
base_dir: base.into(),
|
||||
recursive,
|
||||
..Default::default()
|
||||
},
|
||||
&mut writer,
|
||||
)
|
||||
.await
|
||||
.expect("walk");
|
||||
drop(writer);
|
||||
let entries = MetacacheReader::new(reader).read_all().await.expect("listing stream");
|
||||
let objects: Vec<_> = entries.iter().filter(|entry| !entry.metadata.is_empty()).collect();
|
||||
let expected_names: &[&str] = match (base, recursive) {
|
||||
("", true) => &["folder/", "folder/child", "parent", "parent/child"],
|
||||
("", false) => &["folder/", "parent"],
|
||||
("parent", true) => &["parent/", "parent/child"],
|
||||
("parent/", true) => &["parent/child"],
|
||||
("folder/", true) => &["folder/", "folder/child"],
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(
|
||||
objects.iter().map(|entry| entry.name.as_str()).collect::<Vec<_>>(),
|
||||
expected_names,
|
||||
"base {base}, recursive {recursive}"
|
||||
);
|
||||
for entry in objects {
|
||||
// A walk rooted at a plain object's backing directory emits its
|
||||
// own metadata with a trailing slash, unlike a root-level walk.
|
||||
let physical_name = if base == "parent" && entry.name == "parent/" {
|
||||
"parent"
|
||||
} else {
|
||||
entry.name.as_str()
|
||||
};
|
||||
assert_eq!(
|
||||
&entry.metadata,
|
||||
expected.get(physical_name).expect("known object"),
|
||||
"inline data leaked into listing of {} from base {base}, recursive {recursive}",
|
||||
entry.name
|
||||
);
|
||||
}
|
||||
}
|
||||
// Raw metadata is still required by non-listing recovery consumers.
|
||||
let raw = disk.read_metadata(bucket, "parent/xl.meta").await.expect("raw metadata");
|
||||
assert!(raw.len() > 128 * 1024);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_listing_metadata_bytes_covers_large_envelope_versions_and_non_recursive_walk_coexistence() {
|
||||
use rustfs_filemeta::MetacacheReader;
|
||||
|
||||
@@ -198,7 +198,7 @@ pub(super) fn create_local_inline_rollback_backup(
|
||||
return Err(err);
|
||||
}
|
||||
} else if sync && !old_metadata.is_empty() {
|
||||
os::open_file_for_sync_std(&backup_path)?.sync_data()?;
|
||||
std::fs::File::open(&backup_path)?.sync_data()?;
|
||||
}
|
||||
Ok(backup_path)
|
||||
}
|
||||
@@ -486,9 +486,6 @@ impl LocalDisk {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
})?;
|
||||
crate::hp_guard!("LocalDisk::rename_data");
|
||||
if dst_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
self.multipart_index.record_info(dst_path, &fi).await?;
|
||||
}
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if fi.is_healing() {
|
||||
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
|
||||
@@ -1597,13 +1594,12 @@ impl LocalDisk {
|
||||
);
|
||||
}
|
||||
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() {
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
#[cfg(target_os = "linux")]
|
||||
let payload_guard = if multipart_publication.is_some() {
|
||||
Some(
|
||||
os::multipart_payload_commit_guard(
|
||||
&commit.directory_guard,
|
||||
src_data_path.parent().ok_or(DiskError::InvalidPath)?,
|
||||
commit.publication_root,
|
||||
commit.mutation_lease.clone(),
|
||||
)
|
||||
.await
|
||||
@@ -1612,9 +1608,9 @@ impl LocalDisk {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
#[cfg(target_os = "linux")]
|
||||
let directory_guard = payload_guard.as_ref().unwrap_or(&commit.directory_guard);
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let directory_guard = &commit.directory_guard;
|
||||
if let Err(err) = os::rename_all_with_commit_guard(
|
||||
src_data_path,
|
||||
|
||||
@@ -5,28 +5,6 @@ use crate::disk::{
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(super) struct RecoveryOperationScope {
|
||||
pub(super) budget: Arc<crate::object_api::DeleteRecoveryWorkBudget>,
|
||||
may_publish_receipt: bool,
|
||||
receipt_published: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
static RECOVERY_OPERATION: Arc<RecoveryOperationScope>;
|
||||
}
|
||||
|
||||
pub(super) fn recovery_operation_scope() -> Option<Arc<RecoveryOperationScope>> {
|
||||
RECOVERY_OPERATION.try_with(Arc::clone).ok()
|
||||
}
|
||||
|
||||
impl RecoveryOperationScope {
|
||||
pub(super) fn receipt_published(&self) {
|
||||
if self.may_publish_receipt {
|
||||
self.receipt_published.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const PREPARED: &str = "delete-prepared";
|
||||
pub(super) const TARGET: &str = "delete-target";
|
||||
pub(super) const FINISHED: &str = "delete-commit-finished";
|
||||
@@ -41,35 +19,24 @@ struct DiscoverySession {
|
||||
touched: Instant,
|
||||
}
|
||||
|
||||
/// Plain (non-transaction) children of an object-scoped discovery root cost one
|
||||
/// budget entry per this many names: enough to bound a hostile prefix without
|
||||
/// making a real folder prefix undeletable.
|
||||
const PLAIN_CHILDREN_PER_BUDGET_ENTRY: u32 = 64;
|
||||
|
||||
impl DiscoverySession {
|
||||
/// Consume one bounded step, returning ownership only when traversal remains.
|
||||
fn advance(self, limit: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>)> {
|
||||
self.advance_bounded(limit, super::super::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES)
|
||||
.map(|(transactions, next, _)| (transactions, next))
|
||||
}
|
||||
|
||||
fn advance_bounded(mut self, limit: u32, max_entries: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>, u32)> {
|
||||
fn advance(mut self, limit: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>)> {
|
||||
if self.frames.is_empty() {
|
||||
match std::fs::read_dir(&self.root) {
|
||||
Ok(entries) => self.frames.push((String::new(), entries)),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
return Ok((Vec::new(), None, 0));
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
let mut transactions = BTreeSet::new();
|
||||
let mut seen_candidates = HashSet::new();
|
||||
let mut visited = 0u32;
|
||||
let mut plain_children = 0u32;
|
||||
let mut visited = 0usize;
|
||||
while !self.frames.is_empty()
|
||||
&& transactions.len() < usize::try_from(limit).map_err(|_| std::io::Error::from(ErrorKind::InvalidInput))?
|
||||
&& visited < max_entries
|
||||
&& visited < 4096
|
||||
{
|
||||
let (parent, entries) = self
|
||||
.frames
|
||||
@@ -82,6 +49,7 @@ impl DiscoverySession {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
visited += 1;
|
||||
let name = entry
|
||||
.file_name()
|
||||
.into_string()
|
||||
@@ -89,19 +57,6 @@ impl DiscoverySession {
|
||||
if !safe_path(&name, false) || name.contains('/') {
|
||||
return Err(std::io::Error::from(ErrorKind::InvalidData));
|
||||
}
|
||||
// An object-scoped scan only ever descends into transaction (UUID)
|
||||
// directories at the root. Plain child keys of a prefix directory are
|
||||
// skipped by name without a stat; they are charged at one entry per
|
||||
// readdir batch so the work stays bounded while deleting a missing key
|
||||
// that names a prefix with thousands of children can still complete.
|
||||
if self.object.is_some() && parent.is_empty() && !canonical_uuid(&name).is_some_and(|id| !id.is_nil()) {
|
||||
plain_children += 1;
|
||||
if plain_children.is_multiple_of(PLAIN_CHILDREN_PER_BUDGET_ENTRY) {
|
||||
visited += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
visited += 1;
|
||||
let relative = if parent.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
@@ -201,7 +156,7 @@ impl DiscoverySession {
|
||||
.map(|(object, transaction)| DeleteTransactionId { object, transaction })
|
||||
.collect();
|
||||
let next = (!self.frames.is_empty()).then_some(self);
|
||||
Ok((transactions, next, visited))
|
||||
Ok((transactions, next))
|
||||
}
|
||||
|
||||
async fn has_evidence(mut self, disk: &LocalDisk, path: &Path) -> Result<bool> {
|
||||
@@ -625,41 +580,12 @@ fn explicit_delete_referenced_data_dirs(
|
||||
Ok(Some(referenced))
|
||||
}
|
||||
|
||||
pub(super) async fn next_recovery_entry(entries: &mut tokio::fs::ReadDir) -> std::io::Result<Option<tokio::fs::DirEntry>> {
|
||||
if let Some(scope) = recovery_operation_scope() {
|
||||
scope.budget.reserve(1).map_err(std::io::Error::from)?;
|
||||
}
|
||||
entries.next_entry().await
|
||||
}
|
||||
|
||||
pub(super) fn recovery_entries(
|
||||
mut entries: std::fs::ReadDir,
|
||||
budget: Option<&crate::object_api::DeleteRecoveryWorkBudget>,
|
||||
) -> impl Iterator<Item = std::io::Result<std::fs::DirEntry>> + '_ {
|
||||
let mut finished = false;
|
||||
std::iter::from_fn(move || {
|
||||
if finished {
|
||||
return None;
|
||||
}
|
||||
if let Some(budget) = budget
|
||||
&& let Err(error) = budget.reserve(1)
|
||||
{
|
||||
finished = true;
|
||||
return Some(Err(error.into()));
|
||||
}
|
||||
let entry = entries.next();
|
||||
finished = entry.is_none();
|
||||
entry
|
||||
})
|
||||
}
|
||||
|
||||
fn explicit_delete_plan_bounded(
|
||||
fn explicit_delete_plan(
|
||||
root: &Path,
|
||||
transaction: Uuid,
|
||||
original: Option<&FileMeta>,
|
||||
journal: Option<&DeleteRecoveryJournal>,
|
||||
current_missing: bool,
|
||||
budget: Option<&crate::object_api::DeleteRecoveryWorkBudget>,
|
||||
) -> Result<Option<ExplicitDeletePlan>> {
|
||||
let Some(referenced) = explicit_delete_referenced_data_dirs(original, journal, current_missing)? else {
|
||||
return Ok(None);
|
||||
@@ -676,7 +602,7 @@ fn explicit_delete_plan_bounded(
|
||||
let mut candidates = Vec::new();
|
||||
match std::fs::read_dir(&rollback) {
|
||||
Ok(entries) => {
|
||||
for entry in recovery_entries(entries, budget) {
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(to_file_error)?;
|
||||
let kind = entry.file_type().map_err(to_file_error)?;
|
||||
let name = entry.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
|
||||
@@ -708,7 +634,7 @@ fn explicit_delete_plan_bounded(
|
||||
}
|
||||
match std::fs::read_dir(root) {
|
||||
Ok(entries) => {
|
||||
for entry in recovery_entries(entries, budget) {
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(to_file_error)?;
|
||||
let name = entry.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
|
||||
let Some(id) = canonical_uuid(&name).filter(|id| !id.is_nil()) else {
|
||||
@@ -732,7 +658,7 @@ fn explicit_delete_plan_bounded(
|
||||
candidates.dedup();
|
||||
for dir in candidates {
|
||||
let mut has_parts = false;
|
||||
for entry in recovery_entries(std::fs::read_dir(&dir).map_err(to_file_error)?, budget) {
|
||||
for entry in std::fs::read_dir(&dir).map_err(to_file_error)? {
|
||||
let entry = entry.map_err(to_file_error)?;
|
||||
if !entry.file_type().map_err(to_file_error)?.is_file() {
|
||||
return Ok(None);
|
||||
@@ -771,27 +697,15 @@ fn canonical_uuid(name: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(name).ok().filter(|id| name == id.hyphenated().to_string())
|
||||
}
|
||||
|
||||
pub(super) fn missing_marker_preserves_child_prefix(journal: &DeleteRecoveryJournal) -> bool {
|
||||
journal.original_digest.is_none()
|
||||
&& journal.original_versions.is_empty()
|
||||
&& journal.data_dirs.is_empty()
|
||||
&& journal.target_digest.is_some()
|
||||
&& !journal.requests.is_empty()
|
||||
&& journal
|
||||
.requests
|
||||
.iter()
|
||||
.all(|request| request.deleted && request.version_id.is_some_and(|id| !id.is_nil()))
|
||||
}
|
||||
|
||||
pub(super) fn inspection_with_markers_bounded(
|
||||
pub(super) fn inspection_with_markers(
|
||||
root: &Path,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
budget: Option<&crate::object_api::DeleteRecoveryWorkBudget>,
|
||||
) -> Result<(DeleteRecoveryInspection, bool)> {
|
||||
let rollback = root.join(transaction.to_string());
|
||||
let before = regular_metadata_bytes(&rollback.join(STORAGE_FORMAT_FILE_BACKUP))?;
|
||||
let current = regular_metadata_bytes(&root.join(STORAGE_FORMAT_FILE))?;
|
||||
let prepared = regular_bytes(&rollback.join(PREPARED))?;
|
||||
let target = regular_metadata_bytes(&rollback.join(TARGET))?;
|
||||
let receipt_bytes = regular_bytes(&rollback.join(DELETE_QUORUM_COMMITTED_FILE))?;
|
||||
@@ -825,20 +739,6 @@ pub(super) fn inspection_with_markers_bounded(
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
// A failed creation of a marker over a child named `xl.meta` did not
|
||||
// replace that directory. Older releases nevertheless left a journal.
|
||||
// Only this bound, originally absent generation may treat it as absence.
|
||||
let current_path = root.join(STORAGE_FORMAT_FILE);
|
||||
let current = if !committed
|
||||
&& before.is_none()
|
||||
&& (journal.as_ref().is_some_and(missing_marker_preserves_child_prefix)
|
||||
|| (journal.is_none() && target.is_none() && receipt_bytes.is_none()))
|
||||
&& std::fs::symlink_metadata(¤t_path).is_ok_and(|metadata| metadata.is_dir())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
regular_metadata_bytes(¤t_path)?
|
||||
};
|
||||
let backup_digest = before.as_deref().map(digest);
|
||||
let current_digest = current.as_deref().map(digest);
|
||||
let prepared_digest = prepared.as_deref().map(digest);
|
||||
@@ -881,7 +781,7 @@ pub(super) fn inspection_with_markers_bounded(
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
};
|
||||
if let Some(entries) = entries {
|
||||
for entry in recovery_entries(entries, budget) {
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(to_file_error)?;
|
||||
let kind = entry.file_type().map_err(to_file_error)?;
|
||||
if !kind.is_file() && !kind.is_dir() {
|
||||
@@ -898,7 +798,7 @@ pub(super) fn inspection_with_markers_bounded(
|
||||
descriptions.push((name.clone(), kind.is_dir(), entry.metadata().map_err(to_file_error)?.len()));
|
||||
if kind.is_dir() && id.is_some_and(|id| !id.is_nil()) {
|
||||
has_other_uuid_dir |= id != Some(transaction);
|
||||
for child in recovery_entries(std::fs::read_dir(entry.path()).map_err(to_file_error)?, budget) {
|
||||
for child in std::fs::read_dir(entry.path()).map_err(to_file_error)? {
|
||||
let child = child.map_err(to_file_error)?;
|
||||
let kind = child.file_type().map_err(to_file_error)?;
|
||||
if !kind.is_dir() && !kind.is_file() {
|
||||
@@ -944,7 +844,7 @@ pub(super) fn inspection_with_markers_bounded(
|
||||
}
|
||||
if name == transaction.to_string() && kind.is_dir() {
|
||||
has_staged_dir = true;
|
||||
for file in recovery_entries(std::fs::read_dir(child.path()).map_err(to_file_error)?, budget) {
|
||||
for file in std::fs::read_dir(child.path()).map_err(to_file_error)? {
|
||||
let file = file.map_err(to_file_error)?;
|
||||
let kind = file.file_type().map_err(to_file_error)?;
|
||||
let file_name = file.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
|
||||
@@ -1068,8 +968,7 @@ pub(super) fn inspection_with_markers_bounded(
|
||||
.is_some_and(|referenced| !referenced.contains(&transaction))
|
||||
}
|
||||
DeleteRecoveryState::LegacyAmbiguous | DeleteRecoveryState::NewPrepared => {
|
||||
explicit_delete_plan_bounded(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none(), budget)?
|
||||
.is_some()
|
||||
explicit_delete_plan(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none())?.is_some()
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
@@ -1140,7 +1039,6 @@ impl LocalDisk {
|
||||
let volume = volume.to_owned();
|
||||
let object = object.to_owned();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await;
|
||||
let recovery_scope = recovery_operation_scope();
|
||||
os::run_blocking_namespace_operation(lease, move || {
|
||||
Ok((|| {
|
||||
let entries = match std::fs::read_dir(&root) {
|
||||
@@ -1150,7 +1048,7 @@ impl LocalDisk {
|
||||
};
|
||||
let mut predecessors = BTreeSet::new();
|
||||
let mut visited = 0usize;
|
||||
for entry in recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref())) {
|
||||
for entry in entries {
|
||||
visited += 1;
|
||||
if visited > 65536 {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
@@ -1303,42 +1201,6 @@ impl LocalDisk {
|
||||
.map_err(|error| to_file_error(error).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn public_delete_discovery_sessions_for_test(&self) -> usize {
|
||||
DISCOVERY_SESSIONS
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|session| session.disk_root == self.io_root())
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(super) async fn delete_recovery_discover_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
max_entries: u32,
|
||||
) -> Result<super::super::BoundedDeleteRecoveryDiscovery> {
|
||||
if !(1..=super::super::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES).contains(&max_entries) {
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
let path = self.delete_discovery_path(volume, Some(object))?;
|
||||
let session = self.new_delete_discovery_session(volume, Some(object), &path)?;
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&path, None).await;
|
||||
self.validate_delete_discovery_owner(&session)?;
|
||||
// The blocking owner drops every directory handle even if the RPC waiter disappears.
|
||||
// A partial inventory is never evidence of absence and never enters DISCOVERY_SESSIONS.
|
||||
os::run_blocking_namespace_operation(lease, move || {
|
||||
let (transactions, next, visited) = session.advance_bounded(256, max_entries)?;
|
||||
Ok(super::super::BoundedDeleteRecoveryDiscovery {
|
||||
transactions: if next.is_none() { transactions } else { Vec::new() },
|
||||
visited,
|
||||
complete: next.is_none(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| to_file_error(error).into())
|
||||
}
|
||||
|
||||
pub(super) async fn delete_recovery_discover(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -1386,42 +1248,6 @@ impl LocalDisk {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn delete_recovery_inspect_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
max_entries: u32,
|
||||
) -> Result<super::super::BoundedDeleteRecoveryInspection> {
|
||||
if !safe_path(object, false)
|
||||
|| transaction.is_nil()
|
||||
|| !(1..=super::super::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES).contains(&max_entries)
|
||||
{
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
let root = self.get_object_path(volume, object)?;
|
||||
let owner = os::acquire_metadata_mutation_lease(&root, None).await;
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, Some(owner)).await;
|
||||
let volume = volume.to_owned();
|
||||
let object = object.to_owned();
|
||||
// Same lane as the unbounded inspection: a bulk delete inspects one
|
||||
// transaction per key on every drive, and the one-worker foreground
|
||||
// namespace lane would serialize those behind every other mutation.
|
||||
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
|
||||
let budget = crate::object_api::DeleteRecoveryWorkBudget::new(max_entries);
|
||||
Ok(
|
||||
inspection_with_markers_bounded(&root, &volume, &object, transaction, Some(&budget)).map(|(inspection, _)| {
|
||||
super::super::BoundedDeleteRecoveryInspection {
|
||||
inspection,
|
||||
visited: max_entries - budget.remaining(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?
|
||||
}
|
||||
|
||||
pub(super) async fn delete_recovery_inspect(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -1461,19 +1287,12 @@ impl LocalDisk {
|
||||
let volume = volume.to_owned();
|
||||
let object = object.to_owned();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await;
|
||||
let scope = recovery_operation_scope();
|
||||
// Journal inspections ride the durable-mutation lane (the fused delete
|
||||
// prepare already does): a bulk delete publishes one transaction per
|
||||
// key, and the one-worker foreground namespace lane would serialize
|
||||
// those per-drive publishes behind every other mutation.
|
||||
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
|
||||
Ok(inspection_with_markers_bounded(
|
||||
&root,
|
||||
&volume,
|
||||
&object,
|
||||
transaction,
|
||||
scope.as_ref().map(|scope| scope.budget.as_ref()),
|
||||
))
|
||||
Ok(inspection_with_markers(&root, &volume, &object, transaction))
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?
|
||||
@@ -1674,49 +1493,6 @@ impl LocalDisk {
|
||||
self.activate_delete_preparation(&journal, original, target, owner).await
|
||||
}
|
||||
|
||||
pub(super) async fn delete_recovery_settle_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
request: super::super::BoundedDeleteRecoverySettlement,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<super::super::BoundedDeleteRecoverySettlementReply> {
|
||||
if !(1..=super::super::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES).contains(&request.max_entries) {
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
let scope = Arc::new(RecoveryOperationScope {
|
||||
budget: Arc::new(crate::object_api::DeleteRecoveryWorkBudget::new(request.max_entries)),
|
||||
may_publish_receipt: matches!(
|
||||
&request.action,
|
||||
DeleteRecoveryAction::PublishCommit | DeleteRecoveryAction::AdoptCommit { .. }
|
||||
),
|
||||
receipt_published: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
let result = RECOVERY_OPERATION
|
||||
.scope(
|
||||
Arc::clone(&scope),
|
||||
self.delete_recovery_settle(
|
||||
volume,
|
||||
&request.object,
|
||||
request.transaction,
|
||||
request.expected_fingerprint,
|
||||
request.action,
|
||||
owner,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let outcome = match result {
|
||||
Ok(inspection) => super::super::DeleteRecoverySettlementOutcome::Inspected(Box::new(inspection)),
|
||||
Err(DiskError::DeleteRecoveryWorkLimit) if scope.receipt_published.load(std::sync::atomic::Ordering::Acquire) => {
|
||||
super::super::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
Ok(super::super::BoundedDeleteRecoverySettlementReply {
|
||||
outcome,
|
||||
visited: request.max_entries - scope.budget.remaining(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_recovery_settle(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -1915,7 +1691,6 @@ impl LocalDisk {
|
||||
let plan_root = root.clone();
|
||||
let missing = observed.current_digest.is_none();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner.clone()).await;
|
||||
let scope = recovery_operation_scope();
|
||||
// Same durable-mutation lane as the journal inspection above: this
|
||||
// plan closure runs inside every settle on the bulk-delete publish
|
||||
// path.
|
||||
@@ -1926,15 +1701,7 @@ impl LocalDisk {
|
||||
.as_deref()
|
||||
.map(FileMeta::load)
|
||||
.transpose()?;
|
||||
explicit_delete_plan_bounded(
|
||||
&plan_root,
|
||||
transaction,
|
||||
before.as_ref(),
|
||||
None,
|
||||
missing,
|
||||
scope.as_ref().map(|scope| scope.budget.as_ref()),
|
||||
)?
|
||||
.ok_or(DiskError::FileCorrupt)
|
||||
explicit_delete_plan(&plan_root, transaction, before.as_ref(), None, missing)?.ok_or(DiskError::FileCorrupt)
|
||||
})())
|
||||
})
|
||||
.await
|
||||
@@ -2199,7 +1966,6 @@ impl LocalDisk {
|
||||
let (rollback_buf, root_buf, volume_str, object_str) =
|
||||
(rollback.clone(), root.clone(), volume.to_owned(), object.to_owned());
|
||||
let publication_root = self.publication_root.clone();
|
||||
let scope = recovery_operation_scope();
|
||||
return os::run_blocking_namespace_operation_with_sync(receipt_lease, sync, move || {
|
||||
let _publication_root = publication_root;
|
||||
for (path, _) in &file_leases {
|
||||
@@ -2232,14 +1998,8 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
}
|
||||
let inspected = inspection_with_markers_bounded(
|
||||
&root_buf,
|
||||
&volume_str,
|
||||
&object_str,
|
||||
transaction,
|
||||
scope.as_ref().map(|scope| scope.budget.as_ref()),
|
||||
)
|
||||
.map(|(state, _)| state);
|
||||
let inspected =
|
||||
inspection_with_markers(&root_buf, &volume_str, &object_str, transaction).map(|(state, _)| state);
|
||||
drop((file_leases, prune_dirs));
|
||||
Ok(inspected)
|
||||
})
|
||||
@@ -2396,7 +2156,6 @@ impl LocalDisk {
|
||||
let volume_str = volume.to_owned();
|
||||
let object_str = object.to_owned();
|
||||
|
||||
let recovery_scope = recovery_operation_scope();
|
||||
let res = os::run_blocking_namespace_operation_with_sync(
|
||||
lease,
|
||||
sync,
|
||||
@@ -2443,9 +2202,7 @@ impl LocalDisk {
|
||||
let mut predecessors = BTreeSet::new();
|
||||
let mut sole_transaction = true;
|
||||
if let Ok(entries) = std::fs::read_dir(&file_path) {
|
||||
for (visited, entry) in
|
||||
recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref())).enumerate()
|
||||
{
|
||||
for (visited, entry) in entries.enumerate() {
|
||||
if visited >= 65536 {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
@@ -2658,25 +2415,12 @@ impl LocalDisk {
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn try_publish_prepared_delete_local(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<Option<(DeleteRecoveryJournal, bool, bool)>> {
|
||||
self.try_publish_prepared_delete_local_with_budget(volume, object, transaction, namespace_owner, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn try_publish_prepared_delete_local_with_budget(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
|
||||
) -> Result<Option<(DeleteRecoveryJournal, bool, bool)>> {
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
if !safe_path(object, false) || transaction.is_nil() {
|
||||
@@ -2755,7 +2499,7 @@ impl LocalDisk {
|
||||
if sole_transaction {
|
||||
match std::fs::read_dir(&root) {
|
||||
Ok(entries) => {
|
||||
for entry in recovery_entries(entries, work_budget.as_deref()) {
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let Ok(name) = entry.file_name().into_string() else {
|
||||
sole_transaction = false;
|
||||
@@ -2803,7 +2547,6 @@ impl LocalDisk {
|
||||
.map_err(to_file_error)?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn try_finish_and_finalize_prepared_delete_local(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -2811,26 +2554,6 @@ impl LocalDisk {
|
||||
transaction: Uuid,
|
||||
journal: &DeleteRecoveryJournal,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<bool> {
|
||||
self.try_finish_and_finalize_prepared_delete_local_with_budget(
|
||||
volume,
|
||||
object,
|
||||
transaction,
|
||||
journal,
|
||||
namespace_owner,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn try_finish_and_finalize_prepared_delete_local_with_budget(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
journal: &DeleteRecoveryJournal,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
|
||||
) -> Result<bool> {
|
||||
fn ignore_not_found(res: std::io::Result<()>) -> std::io::Result<()> {
|
||||
match res {
|
||||
@@ -2897,7 +2620,7 @@ impl LocalDisk {
|
||||
had_physical_data_dirs = true;
|
||||
let mut removable = Vec::new();
|
||||
let mut preserve_dir = false;
|
||||
for entry in recovery_entries(entries, work_budget.as_deref()) {
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else {
|
||||
@@ -4401,100 +4124,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_marker_collision_rejects_new_journals_and_recovers_legacy_proof() {
|
||||
let (temp, disk, _info, original) = fixture().await;
|
||||
let root = disk.io_get_object_path("recovery", "object").expect("object path");
|
||||
let staged_child = temp.path().join("child-fixture");
|
||||
fs::rename(&root, &staged_child).await.expect("move intact object");
|
||||
fs::create_dir(&root).await.expect("create parent prefix");
|
||||
fs::rename(staged_child, root.join(STORAGE_FORMAT_FILE))
|
||||
.await
|
||||
.expect("seed an independent object named object/xl.meta");
|
||||
let transaction = Uuid::new_v4();
|
||||
let marker = FileInfo {
|
||||
volume: "recovery".to_owned(),
|
||||
name: "object".to_owned(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
deleted: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
disk.delete_version_recoverable(
|
||||
"recovery",
|
||||
"object",
|
||||
marker.clone(),
|
||||
true,
|
||||
DeleteOptions {
|
||||
old_data_dir: Some(transaction),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a child xl.meta directory cannot be replaced by a marker");
|
||||
assert!(!root.join(transaction.to_string()).exists(), "rejection must precede journal publication");
|
||||
|
||||
// Reproduce the durable evidence written by releases that prepared a
|
||||
// missing-object marker before attempting to replace the directory.
|
||||
disk.prepare_delete_recovery(
|
||||
"recovery",
|
||||
"object",
|
||||
None,
|
||||
&[marker],
|
||||
None,
|
||||
DeletePreparationContext {
|
||||
transaction,
|
||||
bucket_incarnation: None,
|
||||
namespace_owner: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed legacy prepared marker");
|
||||
let journal_path = root.join(transaction.to_string()).join(PREPARED);
|
||||
let journal_bytes = fs::read(&journal_path).await.expect("prepared journal");
|
||||
let mut incompatible: DeleteRecoveryJournal = serde_json::from_slice(&journal_bytes).expect("journal");
|
||||
incompatible.original_digest = Some([7; 32]);
|
||||
fs::write(&journal_path, serde_json::to_vec(&incompatible).expect("serialize journal"))
|
||||
.await
|
||||
.expect("write incompatible evidence");
|
||||
assert!(
|
||||
disk.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.is_err(),
|
||||
"a journal that claims existing metadata cannot ignore a directory collision"
|
||||
);
|
||||
fs::write(&journal_path, journal_bytes)
|
||||
.await
|
||||
.expect("restore exact legacy proof");
|
||||
let observed = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("inspect legacy proof");
|
||||
assert_eq!(observed.state, DeleteRecoveryState::NewPrepared);
|
||||
let recovered = disk
|
||||
.settle_delete_transaction("recovery", "object", transaction, observed.fingerprint, DeleteRecoveryAction::Rollback)
|
||||
.await
|
||||
.expect("rollback legacy marker without unlinking the child directory");
|
||||
assert_eq!(recovered.state, DeleteRecoveryState::Absent);
|
||||
assert!(!root.join(transaction.to_string()).exists());
|
||||
assert_eq!(
|
||||
disk.read_all("recovery", "object/xl.meta/xl.meta")
|
||||
.await
|
||||
.expect("child metadata")
|
||||
.as_ref(),
|
||||
original
|
||||
);
|
||||
assert!(
|
||||
disk.read_version("", "recovery", "object/xl.meta", "", &Default::default())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
discover_all_keys(&disk, Some("object")).await.is_empty(),
|
||||
"no proof may keep replacement heal pending"
|
||||
);
|
||||
}
|
||||
|
||||
// Data movement copies a delete marker onto a target that holds no
|
||||
// metadata yet. Rolling back that sub-quorum write removes the marker it
|
||||
// published, and must not report the backup it never wrote as missing:
|
||||
@@ -6138,152 +5767,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_bounded_inspection_charges_root_and_uuid_children() {
|
||||
let (_temp, disk, _, _) = fixture().await;
|
||||
let transaction = Uuid::new_v4();
|
||||
apply(&disk, transaction).await;
|
||||
let full = disk
|
||||
.inspect_delete_transaction_bounded("recovery", "object", transaction, 65536)
|
||||
.await
|
||||
.expect("full bounded inspection");
|
||||
assert!(full.visited > 1, "both root and transaction directory entries are charged");
|
||||
let error = disk
|
||||
.inspect_delete_transaction_bounded("recovery", "object", transaction, full.visited - 1)
|
||||
.await
|
||||
.expect_err("nested entry exceeds allowance");
|
||||
assert_eq!(error, DiskError::DeleteRecoveryWorkLimit);
|
||||
let unchanged = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("unchanged proof");
|
||||
assert_eq!(full.inspection.fingerprint, unchanged.fingerprint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_receipt_publication_distinguishes_deferred_inspection_from_write_failure() {
|
||||
for write_fails in [false, true] {
|
||||
let (_temp, disk, _, _) = fixture().await;
|
||||
let transaction = Uuid::new_v4();
|
||||
apply(&disk, transaction).await;
|
||||
let full = disk
|
||||
.inspect_delete_transaction_bounded("recovery", "object", transaction, 65536)
|
||||
.await
|
||||
.expect("measure preparation work");
|
||||
let receipt = disk
|
||||
.io_get_object_path("recovery", &format!("object/{transaction}/{DELETE_QUORUM_COMMITTED_FILE}"))
|
||||
.expect("receipt path");
|
||||
if write_fails {
|
||||
let destination = receipt.clone();
|
||||
set_owned_file_before_open(&receipt, move || std::fs::create_dir(&destination).expect("reject receipt rename"));
|
||||
}
|
||||
let result = disk
|
||||
.settle_delete_transaction_bounded(
|
||||
"recovery",
|
||||
super::super::super::BoundedDeleteRecoverySettlement {
|
||||
object: "object".to_owned(),
|
||||
transaction,
|
||||
expected_fingerprint: full.inspection.fingerprint,
|
||||
action: DeleteRecoveryAction::PublishCommit,
|
||||
max_entries: full.visited,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
if write_fails {
|
||||
assert!(result.is_err(), "a failed receipt write never counts as publication");
|
||||
assert!(receipt.is_dir());
|
||||
} else {
|
||||
let result = result.expect("persisted receipt is acknowledged independently of post-write inspection");
|
||||
assert!(matches!(
|
||||
result.outcome,
|
||||
super::super::super::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred
|
||||
));
|
||||
assert_eq!(result.visited, full.visited);
|
||||
let state = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("durable receipt");
|
||||
assert_eq!(state.state, DeleteRecoveryState::QuorumCommitted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_bounded_discovery_refuses_partial_inventory_without_public_cursor() {
|
||||
let (_temp, disk, _, _) = fixture().await;
|
||||
let path = disk.delete_discovery_path("recovery", Some("prefix")).expect("prefix path");
|
||||
fs::create_dir_all(&path).await.expect("physical prefix");
|
||||
// Transaction-shaped children are the entries a bounded probe pays for.
|
||||
for _ in 0..64 {
|
||||
fs::create_dir(path.join(Uuid::new_v4().to_string()))
|
||||
.await
|
||||
.expect("legitimate child");
|
||||
}
|
||||
for _ in 0..140 {
|
||||
let page = disk
|
||||
.discover_delete_transactions_bounded("recovery", "prefix", 8)
|
||||
.await
|
||||
.expect("bounded probe");
|
||||
assert_eq!(page.visited, 8);
|
||||
assert!(!page.complete, "an empty partial page cannot establish absence");
|
||||
assert!(page.transactions.is_empty());
|
||||
}
|
||||
let page = disk
|
||||
.discover_delete_transactions_bounded("recovery", "prefix", 128)
|
||||
.await
|
||||
.expect("complete probe");
|
||||
assert!(page.complete);
|
||||
assert_eq!(page.visited, 64);
|
||||
assert!(page.transactions.is_empty());
|
||||
let transaction = Uuid::new_v4();
|
||||
apply(&disk, transaction).await;
|
||||
let before = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("before probe");
|
||||
let page = disk
|
||||
.discover_delete_transactions_bounded("recovery", "object", 4096)
|
||||
.await
|
||||
.expect("complete evidence");
|
||||
assert!(page.complete);
|
||||
assert!(page.transactions.iter().any(|id| id.transaction == transaction));
|
||||
let after = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("after probe");
|
||||
assert_eq!(before.fingerprint, after.fingerprint);
|
||||
assert!(
|
||||
DISCOVERY_SESSIONS
|
||||
.lock()
|
||||
.values()
|
||||
.all(|session| session.disk_root != disk.io_root())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_cancelled_bounded_discovery_drops_private_session() {
|
||||
let (_temp, disk, _, _) = fixture().await;
|
||||
let path = disk.delete_discovery_path("recovery", Some("object")).expect("probe scope");
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&path, None).await;
|
||||
let mut probe = Box::pin(disk.discover_delete_transactions_bounded("recovery", "object", 4096));
|
||||
assert!(futures::poll!(probe.as_mut()).is_pending());
|
||||
drop(probe);
|
||||
assert!(
|
||||
DISCOVERY_SESSIONS
|
||||
.lock()
|
||||
.values()
|
||||
.all(|session| session.disk_root != disk.io_root())
|
||||
);
|
||||
drop(lease);
|
||||
assert!(
|
||||
disk.discover_delete_transactions_bounded("recovery", "object", 4096)
|
||||
.await
|
||||
.expect("subsequent probe")
|
||||
.complete
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_evidence_probes_do_not_register_public_cursors() {
|
||||
let (_temp, disk, _, _) = fixture().await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,6 @@ pub(super) struct LoadedSource {
|
||||
}
|
||||
|
||||
pub(super) struct SourceStore {
|
||||
multipart_index: Arc<super::multipart_index::IndexOwner>,
|
||||
pub disk_id: Uuid,
|
||||
pub physical_slot: usize,
|
||||
pub disk_ids: Vec<Uuid>,
|
||||
@@ -187,7 +186,6 @@ impl LocalDisk {
|
||||
let logical = self.get_object_path(RUSTFS_META_MULTIPART_BUCKET, path)?;
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&logical, owner.clone()).await;
|
||||
Ok(SourceStore {
|
||||
multipart_index: self.multipart_index.clone(),
|
||||
disk_id,
|
||||
physical_slot,
|
||||
disk_ids,
|
||||
@@ -757,9 +755,6 @@ impl SourceStore {
|
||||
os::sync_multipart_owned_record_parents_options(source, parent, existed, sync_source_root, base, root)
|
||||
}
|
||||
pub async fn replace_head(&self, bytes: &[u8]) -> Result<()> {
|
||||
self.multipart_index
|
||||
.record_head(&format!("{}/xl.meta", self.path), bytes)
|
||||
.await?;
|
||||
invalidate_active_multipart_source_head(&self.source);
|
||||
let path = self.source.join(STORAGE_FORMAT_FILE);
|
||||
let base = self.base.clone();
|
||||
@@ -1517,7 +1512,7 @@ impl LocalDisk {
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let directory_pin = os::pin_multipart_source_directory(&directory, base, root)?;
|
||||
let _directory = os::pin_multipart_source_directory(&directory, base, root)?;
|
||||
if !parts.is_empty() {
|
||||
let anchor_path = directory.join(RETIREMENT_ANCHOR);
|
||||
let bytes = os::read_multipart_source_metadata(&anchor_path, base, root)?;
|
||||
@@ -1550,13 +1545,7 @@ impl LocalDisk {
|
||||
}
|
||||
// An anchor-less empty DD is only rmdir'ed. A new child makes
|
||||
// this fail closed; no recursive removal can consume its bytes.
|
||||
// A Windows pin refuses DELETE sharing, so the leaf pin is
|
||||
// released first; the object pin still holds the parent.
|
||||
#[cfg(windows)]
|
||||
drop(directory_pin);
|
||||
std::fs::remove_dir(&directory)?;
|
||||
#[cfg(not(windows))]
|
||||
drop(directory_pin);
|
||||
if sync {
|
||||
os::fsync_dir_std(&object_path)?;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ mod io_admission;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod lifecycle_test_io;
|
||||
pub mod local;
|
||||
pub mod multipart_index;
|
||||
pub mod multipart_source;
|
||||
pub mod os;
|
||||
|
||||
@@ -53,10 +52,6 @@ pub const RUSTFS_META_MULTIPART_BUCKET: &str = ".rustfs.sys/multipart";
|
||||
pub const RUSTFS_META_TMP_BUCKET: &str = ".rustfs.sys/tmp";
|
||||
pub const RUSTFS_META_TMP_DELETED_BUCKET: &str = ".rustfs.sys/tmp/.trash";
|
||||
pub const BUCKET_META_PREFIX: &str = "buckets";
|
||||
/// Directory under `BUCKET_META_PREFIX` that holds drive-replacement heal
|
||||
/// intents, seals and recovery proofs. It shares the bucket-metadata root with
|
||||
/// a user bucket of the same name, so bucket deletion must leave it in place.
|
||||
pub const HEAL_REPLACEMENT_RECOVERY_DIR: &str = "ahm-replacement";
|
||||
pub const FORMAT_CONFIG_FILE: &str = "format.json";
|
||||
/// Per-disk marker present while an erasure-set heal is rebuilding this disk.
|
||||
/// `LocalDisk::disk_info` reports `healing = true` while the file exists.
|
||||
@@ -419,11 +414,10 @@ impl Disk {
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
|
||||
) -> Result<Option<(DeleteRecoveryJournal, bool, bool)>> {
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.try_publish_prepared_delete_local(volume, object, transaction, namespace_owner, work_budget)
|
||||
disk.try_publish_prepared_delete_local(volume, object, transaction, namespace_owner)
|
||||
.await
|
||||
}
|
||||
Self::Remote(_) => Ok(None),
|
||||
@@ -437,19 +431,11 @@ impl Disk {
|
||||
transaction: Uuid,
|
||||
journal: &DeleteRecoveryJournal,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.try_finish_and_finalize_prepared_delete_local(
|
||||
volume,
|
||||
object,
|
||||
transaction,
|
||||
journal,
|
||||
namespace_owner,
|
||||
work_budget,
|
||||
)
|
||||
.await
|
||||
disk.try_finish_and_finalize_prepared_delete_local(volume, object, transaction, journal, namespace_owner)
|
||||
.await
|
||||
}
|
||||
Self::Remote(_) => Ok(false),
|
||||
}
|
||||
@@ -526,17 +512,6 @@ impl Disk {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for Disk {
|
||||
async fn list_multipart_index_roots(
|
||||
&self,
|
||||
bucket: &str,
|
||||
marker: Option<&str>,
|
||||
) -> Result<multipart_index::MultipartIndexPage> {
|
||||
match self {
|
||||
Self::Local(disk) => disk.list_multipart_index_roots(bucket, marker).await,
|
||||
Self::Remote(disk) => disk.list_multipart_index_roots(bucket, marker).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn supports_guarded_delete_versions(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::Local(disk) => disk.supports_guarded_delete_versions().await,
|
||||
@@ -580,17 +555,6 @@ impl DiskAPI for Disk {
|
||||
Self::Remote(disk) => disk.has_delete_transaction_evidence(volume, object).await,
|
||||
}
|
||||
}
|
||||
async fn discover_delete_transactions_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
max_entries: u32,
|
||||
) -> Result<BoundedDeleteRecoveryDiscovery> {
|
||||
match self {
|
||||
Self::Local(disk) => disk.discover_delete_transactions_bounded(volume, object, max_entries).await,
|
||||
Self::Remote(disk) => disk.discover_delete_transactions_bounded(volume, object, max_entries).await,
|
||||
}
|
||||
}
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -603,35 +567,6 @@ impl DiskAPI for Disk {
|
||||
Self::Remote(disk) => disk.discover_delete_transactions(volume, object, cursor, limit).await,
|
||||
}
|
||||
}
|
||||
async fn settle_delete_transaction_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
request: BoundedDeleteRecoverySettlement,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<BoundedDeleteRecoverySettlementReply> {
|
||||
match self {
|
||||
Self::Local(disk) => disk.settle_delete_transaction_bounded(volume, request, namespace_owner).await,
|
||||
Self::Remote(disk) => disk.settle_delete_transaction_bounded(volume, request, namespace_owner).await,
|
||||
}
|
||||
}
|
||||
async fn inspect_delete_transaction_bounded(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
max_entries: u32,
|
||||
) -> Result<BoundedDeleteRecoveryInspection> {
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.inspect_delete_transaction_bounded(volume, object, transaction, max_entries)
|
||||
.await
|
||||
}
|
||||
Self::Remote(disk) => {
|
||||
disk.inspect_delete_transaction_bounded(volume, object, transaction, max_entries)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn inspect_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -1578,15 +1513,6 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
|
||||
fn host_name(&self) -> String;
|
||||
fn endpoint(&self) -> Endpoint;
|
||||
async fn close(&self) -> Result<()>;
|
||||
/// Return candidates only while this disk maintains complete boot-local coverage.
|
||||
/// Unsupported or unready disks must never return an authoritative empty page.
|
||||
async fn list_multipart_index_roots(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_marker: Option<&str>,
|
||||
) -> Result<multipart_index::MultipartIndexPage> {
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
}
|
||||
async fn get_disk_id(&self) -> Result<Option<Uuid>>;
|
||||
async fn set_disk_id(&self, id: Option<Uuid>) -> Result<()>;
|
||||
|
||||
@@ -1764,35 +1690,6 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
|
||||
) -> Result<DeleteRecoveryPage> {
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
}
|
||||
/// Bounded foreground inventory owns all traversal state until return or cancellation.
|
||||
/// An implementation without the bounded protocol is an unsupported participant
|
||||
/// (the same verdict a remote peer without it receives); callers must never fall
|
||||
/// back to paginated discovery.
|
||||
async fn discover_delete_transactions_bounded(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_object: &str,
|
||||
_max_entries: u32,
|
||||
) -> Result<BoundedDeleteRecoveryDiscovery> {
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
}
|
||||
async fn inspect_delete_transaction_bounded(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_object: &str,
|
||||
_transaction: Uuid,
|
||||
_max_entries: u32,
|
||||
) -> Result<BoundedDeleteRecoveryInspection> {
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
}
|
||||
async fn settle_delete_transaction_bounded(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_request: BoundedDeleteRecoverySettlement,
|
||||
_namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<BoundedDeleteRecoverySettlementReply> {
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
}
|
||||
/// A read-only capability check; unsupported implementations must fail closed.
|
||||
async fn supports_guarded_delete_versions(&self) -> Result<()> {
|
||||
Err(DiskError::GuardedDeleteUnsupported)
|
||||
@@ -2178,13 +2075,6 @@ pub struct WalkDirOptions {
|
||||
#[serde(default)]
|
||||
pub skip_hidden_prefix_check: bool,
|
||||
|
||||
/// Bound hidden metadata read while proving a non-recursive prefix visible.
|
||||
#[serde(default)]
|
||||
pub hidden_entry_budget: usize,
|
||||
/// A prefix whose visibility was not established before the preceding cut.
|
||||
#[serde(default)]
|
||||
pub pending_prefix: Option<String>,
|
||||
|
||||
// ReportNotFound will return errFileNotFound if all disks reports the BaseDir cannot be found.
|
||||
pub report_notfound: bool,
|
||||
|
||||
@@ -2517,62 +2407,9 @@ pub enum DeleteRecoveryAction {
|
||||
AdoptCommit { authority: Box<DeleteRecoveryJournal> },
|
||||
}
|
||||
|
||||
/// A complete inventory or a work-limit refusal. No public discovery cursor is allocated.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BoundedDeleteRecoveryDiscovery {
|
||||
pub transactions: Vec<DeleteTransactionId>,
|
||||
pub visited: u32,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
pub const DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES: u32 = 4096;
|
||||
pub const DELETE_RECOVERY_MAX_INSPECTION_ENTRIES: u32 = 65536;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BoundedDeleteRecoverySettlement {
|
||||
pub object: String,
|
||||
pub transaction: Uuid,
|
||||
pub expected_fingerprint: DeleteRecoveryDigest,
|
||||
pub action: DeleteRecoveryAction,
|
||||
pub max_entries: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DeleteRecoverySettlementOutcome {
|
||||
Inspected(Box<DeleteRecoveryInspection>),
|
||||
ReceiptPublishedInspectionDeferred,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BoundedDeleteRecoverySettlementReply {
|
||||
pub outcome: DeleteRecoverySettlementOutcome,
|
||||
pub visited: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BoundedDeleteRecoveryInspection {
|
||||
pub inspection: DeleteRecoveryInspection,
|
||||
pub visited: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DeleteRecoveryRpcOperation {
|
||||
SettleBounded(BoundedDeleteRecoverySettlement),
|
||||
InspectBounded {
|
||||
object: String,
|
||||
transaction: Uuid,
|
||||
max_entries: u32,
|
||||
},
|
||||
DiscoverBounded {
|
||||
object: String,
|
||||
max_entries: u32,
|
||||
},
|
||||
InspectOrphanDataDir {
|
||||
path: String,
|
||||
},
|
||||
@@ -2610,11 +2447,7 @@ impl DeleteRecoveryRpcOperation {
|
||||
/// Full metadata application and adoption share the existing mutation envelope.
|
||||
pub fn max_wire_payload_bytes(&self) -> usize {
|
||||
match self {
|
||||
Self::SettleBounded(BoundedDeleteRecoverySettlement {
|
||||
action: DeleteRecoveryAction::AdoptCommit { .. },
|
||||
..
|
||||
})
|
||||
| Self::ApplyVersion { .. }
|
||||
Self::ApplyVersion { .. }
|
||||
| Self::ApplyVersions { .. }
|
||||
| Self::Settle {
|
||||
action: DeleteRecoveryAction::AdoptCommit { .. },
|
||||
@@ -2630,11 +2463,8 @@ impl DeleteRecoveryRpcOperation {
|
||||
pub enum DeleteRecoveryRpcReply {
|
||||
OrphanDataDir { plain: bool },
|
||||
Discovered(DeleteRecoveryPage),
|
||||
DiscoveredBounded(BoundedDeleteRecoveryDiscovery),
|
||||
Inspected(DeleteRecoveryInspection),
|
||||
InspectedBounded(BoundedDeleteRecoveryInspection),
|
||||
Settled(DeleteRecoveryInspection),
|
||||
SettledBounded(BoundedDeleteRecoverySettlementReply),
|
||||
AppliedVersion,
|
||||
AppliedVersions(Vec<Option<u32>>),
|
||||
Evidence { present: bool },
|
||||
@@ -3112,8 +2942,6 @@ mod tests {
|
||||
recursive: true,
|
||||
incl_deleted: false,
|
||||
skip_hidden_prefix_check: false,
|
||||
hidden_entry_budget: 0,
|
||||
pending_prefix: None,
|
||||
report_notfound: false,
|
||||
filter_prefix: Some("prefix_".to_string()),
|
||||
forward_to: Some("object/path".to_string()),
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! A candidate index never replaces multipart metadata or quorum validation.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) const RUSTFS_MULTIPART_BUCKET_KEY: &str = "x-rustfs-internal-multipart-bucket";
|
||||
pub(crate) const RUSTFS_MULTIPART_OBJECT_KEY: &str = "x-rustfs-internal-multipart-object";
|
||||
|
||||
pub(crate) const MULTIPART_INDEX_PAGE_SIZE: usize = 256;
|
||||
/// Pages one listing accepts from one disk. Pruned pages may carry few roots, so
|
||||
/// the cursor alone no longer bounds a peer's answer; a disk that keeps
|
||||
/// continuing past this many pages contributes no coverage.
|
||||
pub(crate) const MULTIPART_INDEX_MAX_PAGES_PER_DISK: usize = 4096;
|
||||
|
||||
/// A bounded page from one disk's continuously maintained, boot-local index.
|
||||
/// `epoch` changes whenever coverage must be rebuilt; callers must not combine
|
||||
/// pages from different epochs or physical disk identities.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MultipartIndexPage {
|
||||
pub bucket: String,
|
||||
pub disk_id: Uuid,
|
||||
pub epoch: Uuid,
|
||||
pub roots: Vec<String>,
|
||||
pub next_marker: Option<String>,
|
||||
}
|
||||
|
||||
pub const MULTIPART_INDEX_RPC_MAX_BYTES: usize = 64 * 1024;
|
||||
pub const MULTIPART_INDEX_RPC_MAX_REQUEST_BYTES: usize = 4096;
|
||||
|
||||
/// Additive operation inside the legacy read-only DiskInfo JSON envelope.
|
||||
/// Old peers may ignore this field, but their ordinary response cannot decode
|
||||
/// as the required signed, challenge-bound MultipartIndexRpcReply.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MultipartIndexRpcRequest {
|
||||
pub disk_id: String,
|
||||
pub metrics: bool,
|
||||
pub noop: bool,
|
||||
pub multipart_index: MultipartIndexQuery,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MultipartIndexQuery {
|
||||
pub operation: String,
|
||||
pub version: u32,
|
||||
pub challenge: Uuid,
|
||||
pub bucket: String,
|
||||
pub marker: Option<String>,
|
||||
pub page_cap: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MultipartIndexRpcReply {
|
||||
pub operation: String,
|
||||
pub version: u32,
|
||||
pub challenge: Uuid,
|
||||
pub page: MultipartIndexPage,
|
||||
pub proof: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MultipartIndexRpcRequest {
|
||||
pub fn new(bucket: &str, marker: Option<&str>) -> Self {
|
||||
Self {
|
||||
disk_id: String::new(),
|
||||
metrics: false,
|
||||
noop: true,
|
||||
multipart_index: MultipartIndexQuery {
|
||||
operation: "list_multipart_index_roots".into(),
|
||||
version: 1,
|
||||
challenge: Uuid::new_v4(),
|
||||
bucket: bucket.to_owned(),
|
||||
marker: marker.map(str::to_owned),
|
||||
page_cap: MULTIPART_INDEX_PAGE_SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> crate::disk::error::Result<()> {
|
||||
let query = &self.multipart_index;
|
||||
if !self.disk_id.is_empty()
|
||||
|| self.metrics
|
||||
|| !self.noop
|
||||
|| query.operation != "list_multipart_index_roots"
|
||||
|| query.version != 1
|
||||
|| query.challenge.is_nil()
|
||||
|| query.bucket.is_empty()
|
||||
|| query.bucket.len() > 255
|
||||
|| query.bucket.contains('/')
|
||||
|| matches!(query.bucket.as_str(), "." | "..")
|
||||
|| query.page_cap != MULTIPART_INDEX_PAGE_SIZE
|
||||
|| query.marker.as_ref().is_some_and(|marker| !valid_root(marker))
|
||||
{
|
||||
return Err(crate::disk::error::DiskError::MethodNotAllowed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn canonical_request(&self, disk: &str) -> crate::disk::error::Result<Vec<u8>> {
|
||||
self.validate()?;
|
||||
if disk.len() > MULTIPART_INDEX_RPC_MAX_REQUEST_BYTES {
|
||||
return Err(crate::disk::error::DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(serde_json::to_vec(&("rustfs-multipart-index-request-v1", disk, self))?)
|
||||
}
|
||||
|
||||
pub fn canonical_response(&self, disk: &str, page: &MultipartIndexPage) -> crate::disk::error::Result<Vec<u8>> {
|
||||
self.validate()?;
|
||||
Ok(serde_json::to_vec(&("rustfs-multipart-index-response-v1", disk, self, page))?)
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_root(root: &str) -> bool {
|
||||
root.len() == 64 && root.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
impl MultipartIndexPage {
|
||||
pub fn validate(&self, bucket: &str, marker: Option<&str>, expected_disk: Uuid) -> crate::disk::error::Result<()> {
|
||||
if expected_disk.is_nil()
|
||||
|| self.disk_id != expected_disk
|
||||
|| self.epoch.is_nil()
|
||||
|| self.bucket != bucket
|
||||
|| self.roots.len() > MULTIPART_INDEX_PAGE_SIZE
|
||||
|| self
|
||||
.roots
|
||||
.iter()
|
||||
.any(|root| !valid_root(root) || marker.is_some_and(|marker| root.as_str() <= marker))
|
||||
|| self.roots.windows(2).any(|pair| pair[0] >= pair[1])
|
||||
// The cursor names the last examined marker; pruning may leave fewer
|
||||
// live roots than examined names, so it only has to bound them.
|
||||
|| self.next_marker.as_ref().is_some_and(|next| {
|
||||
!valid_root(next)
|
||||
|| marker.is_some_and(|marker| next.as_str() <= marker)
|
||||
|| self.roots.last().is_some_and(|last| last > next)
|
||||
})
|
||||
{
|
||||
return Err(crate::disk::error::DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MultipartIndexRpcReply {
|
||||
/// This is structural validation only; the caller must also verify `proof`.
|
||||
pub fn validate(&self, request: &MultipartIndexRpcRequest, expected_disk: Uuid) -> crate::disk::error::Result<()> {
|
||||
let query = &request.multipart_index;
|
||||
request.validate()?;
|
||||
if self.operation != query.operation
|
||||
|| self.version != query.version
|
||||
|| self.challenge != query.challenge
|
||||
|| self.proof.len() != 32
|
||||
{
|
||||
return Err(crate::disk::error::DiskError::FileCorrupt);
|
||||
}
|
||||
self.page.validate(&query.bucket, query.marker.as_deref(), expected_disk)
|
||||
}
|
||||
}
|
||||
|
||||
/// A disk can attest coverage only after every page belongs to the same physical
|
||||
/// identity and index generation. An interrupted/changed stream contributes no
|
||||
/// partial candidates to the erasure set's coverage count.
|
||||
pub(crate) async fn collect_pages<F, Fut>(bucket: &str, identity: Uuid, mut fetch: F) -> crate::disk::error::Result<Vec<String>>
|
||||
where
|
||||
F: FnMut(Option<String>) -> Fut,
|
||||
Fut: std::future::Future<Output = crate::disk::error::Result<MultipartIndexPage>>,
|
||||
{
|
||||
let mut epoch = None;
|
||||
let mut marker = None;
|
||||
let mut roots = Vec::new();
|
||||
let mut pages = 0_usize;
|
||||
loop {
|
||||
if pages >= MULTIPART_INDEX_MAX_PAGES_PER_DISK {
|
||||
return Err(crate::disk::error::DiskError::FileCorrupt);
|
||||
}
|
||||
pages += 1;
|
||||
let page = fetch(marker.clone()).await?;
|
||||
page.validate(bucket, marker.as_deref(), identity)?;
|
||||
if epoch.is_some_and(|epoch| epoch != page.epoch) {
|
||||
return Err(crate::disk::error::DiskError::DiskNotFound);
|
||||
}
|
||||
epoch = Some(page.epoch);
|
||||
roots.extend(page.roots);
|
||||
marker = page.next_marker;
|
||||
if marker.is_none() {
|
||||
return Ok(roots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn multipart_index_reply_rejects_challenge_disk_cursor_and_unbounded_pages() {
|
||||
let request = MultipartIndexRpcRequest::new("bucket", None);
|
||||
let id = Uuid::new_v4();
|
||||
let mut reply = MultipartIndexRpcReply {
|
||||
operation: request.multipart_index.operation.clone(),
|
||||
version: 1,
|
||||
challenge: request.multipart_index.challenge,
|
||||
page: MultipartIndexPage {
|
||||
bucket: "bucket".into(),
|
||||
disk_id: id,
|
||||
epoch: Uuid::new_v4(),
|
||||
roots: vec!["a".repeat(64)],
|
||||
next_marker: None,
|
||||
},
|
||||
proof: vec![0; 32],
|
||||
};
|
||||
reply.validate(&request, id).expect("valid structural envelope");
|
||||
assert!(
|
||||
reply.validate(&MultipartIndexRpcRequest::new("bucket", None), id).is_err(),
|
||||
"old challenge cannot replay"
|
||||
);
|
||||
assert!(reply.validate(&request, Uuid::new_v4()).is_err(), "physical disk identity cannot change");
|
||||
reply.page.next_marker = Some("9".repeat(64));
|
||||
assert!(reply.validate(&request, id).is_err(), "a cursor below the last root would re-list it");
|
||||
reply.page.next_marker = Some("a".repeat(64));
|
||||
reply
|
||||
.validate(&request, id)
|
||||
.expect("a pruned short page continues from its last examined name");
|
||||
reply.page.next_marker = None;
|
||||
reply.page.roots = vec!["a".repeat(64); MULTIPART_INDEX_PAGE_SIZE + 1];
|
||||
assert!(reply.validate(&request, id).is_err(), "peer cannot enlarge the fixed page cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_index_response_proof_binds_the_complete_query_and_page() {
|
||||
crate::runtime::sources::ensure_test_rpc_secret();
|
||||
let mut request = MultipartIndexRpcRequest::new("bucket", None);
|
||||
let mut page = MultipartIndexPage {
|
||||
bucket: "bucket".into(),
|
||||
disk_id: Uuid::new_v4(),
|
||||
epoch: Uuid::new_v4(),
|
||||
roots: vec![],
|
||||
next_marker: None,
|
||||
};
|
||||
let canonical = request.canonical_response("disk", &page).expect("canonical response");
|
||||
let proof = crate::cluster::rpc::sign_tonic_rpc_response_proof(&canonical).expect("signed response");
|
||||
let verify = |request: &MultipartIndexRpcRequest, page: &MultipartIndexPage| {
|
||||
crate::cluster::rpc::verify_tonic_rpc_response_proof(
|
||||
&request.canonical_response("disk", page).expect("canonical"),
|
||||
&proof,
|
||||
)
|
||||
};
|
||||
verify(&request, &page).expect("exact response");
|
||||
request.multipart_index.marker = Some("0".repeat(64));
|
||||
assert!(verify(&request, &page).is_err(), "cursor is signed");
|
||||
request.multipart_index.marker = None;
|
||||
page.epoch = Uuid::new_v4();
|
||||
assert!(verify(&request, &page).is_err(), "boot-local coverage epoch is signed");
|
||||
}
|
||||
#[test]
|
||||
fn multipart_index_page_cursor_bounds_roots_without_requiring_a_full_page() {
|
||||
let disk = Uuid::new_v4();
|
||||
let page = |roots: Vec<String>, next_marker: Option<String>| MultipartIndexPage {
|
||||
bucket: "bucket".into(),
|
||||
disk_id: disk,
|
||||
epoch: Uuid::new_v4(),
|
||||
roots,
|
||||
next_marker,
|
||||
};
|
||||
// Pruning leaves fewer live roots than examined names; the cursor still advances.
|
||||
page(vec![format!("{:064x}", 1)], Some(format!("{:064x}", 5)))
|
||||
.validate("bucket", None, disk)
|
||||
.expect("short page with a cursor");
|
||||
page(vec![], Some(format!("{:064x}", 5)))
|
||||
.validate("bucket", Some(&format!("{:064x}", 2)), disk)
|
||||
.expect("all examined names pruned");
|
||||
assert!(
|
||||
page(vec![format!("{:064x}", 6)], Some(format!("{:064x}", 5)))
|
||||
.validate("bucket", None, disk)
|
||||
.is_err(),
|
||||
"a root beyond the cursor would be re-listed"
|
||||
);
|
||||
assert!(
|
||||
page(vec![], Some(format!("{:064x}", 2)))
|
||||
.validate("bucket", Some(&format!("{:064x}", 2)), disk)
|
||||
.is_err(),
|
||||
"the cursor must advance past the request marker"
|
||||
);
|
||||
assert!(
|
||||
page(vec![], Some("not-a-sha".to_owned()))
|
||||
.validate("bucket", None, disk)
|
||||
.is_err(),
|
||||
"the cursor is a marker name"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_index_page_collection_bounds_the_pages_one_disk_may_answer() {
|
||||
let id = Uuid::new_v4();
|
||||
let epoch = Uuid::new_v4();
|
||||
let mut served = 0_usize;
|
||||
let result = collect_pages("bucket", id, |marker| {
|
||||
served += 1;
|
||||
let next = marker.map_or(0_u64, |marker| u64::from_str_radix(&marker[48..], 16).expect("hex cursor") + 1);
|
||||
std::future::ready(Ok(MultipartIndexPage {
|
||||
bucket: "bucket".into(),
|
||||
disk_id: id,
|
||||
epoch,
|
||||
roots: vec![],
|
||||
next_marker: Some(format!("{next:064x}")),
|
||||
}))
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(crate::disk::error::DiskError::FileCorrupt)), "{result:?}");
|
||||
assert_eq!(served, MULTIPART_INDEX_MAX_PAGES_PER_DISK, "the peer is cut off at the page cap");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_index_page_collection_rejects_replaced_disk_or_restarted_epoch() {
|
||||
let id = Uuid::new_v4();
|
||||
let epoch = Uuid::new_v4();
|
||||
for replace_disk in [false, true] {
|
||||
let first = MultipartIndexPage {
|
||||
bucket: "bucket".into(),
|
||||
disk_id: id,
|
||||
epoch,
|
||||
roots: (0..MULTIPART_INDEX_PAGE_SIZE).map(|index| format!("{index:064x}")).collect(),
|
||||
next_marker: Some(format!("{:064x}", MULTIPART_INDEX_PAGE_SIZE - 1)),
|
||||
};
|
||||
let second = MultipartIndexPage {
|
||||
bucket: "bucket".into(),
|
||||
disk_id: if replace_disk { Uuid::new_v4() } else { id },
|
||||
epoch: if replace_disk { epoch } else { Uuid::new_v4() },
|
||||
roots: vec![],
|
||||
next_marker: None,
|
||||
};
|
||||
let mut replies = std::collections::VecDeque::from([first, second]);
|
||||
let result = collect_pages("bucket", id, |marker| {
|
||||
let page = replies.pop_front().expect("bounded fixture pages");
|
||||
if marker.is_some() {
|
||||
assert!(page.roots.is_empty());
|
||||
}
|
||||
std::future::ready(Ok(page))
|
||||
})
|
||||
.await;
|
||||
// A replaced disk fails the page's identity check; a restarted
|
||||
// epoch fails the cross-page continuity check.
|
||||
let expected_corrupt = replace_disk;
|
||||
assert!(
|
||||
match &result {
|
||||
Err(crate::disk::error::DiskError::FileCorrupt) => expected_corrupt,
|
||||
Err(crate::disk::error::DiskError::DiskNotFound) => !expected_corrupt,
|
||||
_ => false,
|
||||
},
|
||||
"changed physical source cannot contribute partial coverage: {result:?}"
|
||||
);
|
||||
assert!(replies.is_empty(), "exercise the continuation response");
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
-494
@@ -610,26 +610,6 @@ pub(crate) mod prepare_rename_test_hooks {
|
||||
hook();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hooks keyed by a directory component name, run before a Windows
|
||||
/// directory pin retries a transient sharing conflict on it.
|
||||
#[cfg(windows)]
|
||||
static BEFORE_COMPONENT_RETRY: LazyLock<Mutex<HashMap<std::ffi::OsString, Hook>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn install_before_component_retry(component: &std::ffi::OsStr, hook: impl FnOnce() + Send + 'static) {
|
||||
BEFORE_COMPONENT_RETRY.lock().insert(component.to_os_string(), Box::new(hook));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn run_before_component_retry(component: &std::ffi::OsStr) {
|
||||
let hook = BEFORE_COMPONENT_RETRY.lock().remove(component);
|
||||
if let Some(hook) = hook {
|
||||
let _pause = super::enter_blocking_test_hook();
|
||||
hook();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fsync a directory so recently created or renamed entries survive power loss.
|
||||
@@ -664,16 +644,6 @@ pub(crate) fn fsync_directory_handle_std(dir: &Path, opened: Option<&std::fs::Fi
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open an existing file to read it and flush it. Windows flushes only a
|
||||
/// handle with write access, so the file is opened read-write there; other
|
||||
/// platforms keep the read-only open that `sync_all` already accepts.
|
||||
pub(crate) fn open_file_for_sync_std(path: &Path) -> io::Result<std::fs::File> {
|
||||
#[cfg(windows)]
|
||||
return std::fs::OpenOptions::new().read(true).write(true).open(path);
|
||||
#[cfg(not(windows))]
|
||||
std::fs::File::open(path)
|
||||
}
|
||||
|
||||
/// Async wrapper around [`fsync_dir_std`]; runs the blocking Unix fsync off the runtime.
|
||||
pub async fn fsync_dir(dir: impl AsRef<Path>) -> io::Result<()> {
|
||||
fsync_dir_with_owner(dir.as_ref(), None).await
|
||||
@@ -2761,11 +2731,6 @@ pub(crate) struct NamespaceMutationLease {
|
||||
}
|
||||
|
||||
impl NamespaceMutationLease {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn blocking_queue_for_test(&self) -> Arc<DiskBlockingQueue> {
|
||||
self.blocking_queue.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn attach_external_guard(&self, guard: Arc<dyn Send + Sync>) {
|
||||
*self.external_guard.lock() = Some(guard);
|
||||
}
|
||||
@@ -4614,7 +4579,7 @@ pub(crate) fn rename_source_is_missing(src_file_path: &Path, publication_root: &
|
||||
let Some(source_parent) = src_file_path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let source_parent_guard = match lock_windows_directory_tree(source_parent, publication_root) {
|
||||
let source_parent_guard = match lock_windows_directory_tree(source_parent, None, publication_root) {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => return err.kind() == io::ErrorKind::NotFound,
|
||||
};
|
||||
@@ -5036,7 +5001,7 @@ fn prepare_rename_with_retry(
|
||||
open_windows_rename_source_identity(src_file_path, &source_parent_guard)?;
|
||||
(source_parent_guard, parent_guard, source_identity_anchor, expected_source_identity)
|
||||
} else {
|
||||
let source_parent_guard = lock_windows_directory_tree(source_parent, publication_root)?;
|
||||
let source_parent_guard = lock_windows_directory_tree(source_parent, destination_parent, publication_root)?;
|
||||
let (source_identity_anchor, expected_source_identity) =
|
||||
open_windows_rename_source_identity(src_file_path, &source_parent_guard)?;
|
||||
let parent_guard = prepare_destination_parent()?;
|
||||
@@ -5417,29 +5382,6 @@ impl PublicationRoot {
|
||||
})
|
||||
}
|
||||
|
||||
/// Distinguish reconnects from replacement mounts before reusing disk-local state.
|
||||
pub(crate) fn physical_identity(&self) -> io::Result<(u64, [u8; 16])> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
#[cfg(target_os = "linux")]
|
||||
let metadata = self.directory.metadata()?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let metadata = std::fs::metadata(&*self.path)?;
|
||||
let mut inode = [0; 16];
|
||||
inode[..8].copy_from_slice(&metadata.ino().to_le_bytes());
|
||||
Ok((metadata.dev(), inode))
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows_file_identity(&self.directory.handle)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "physical root identity unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
@@ -5457,9 +5399,7 @@ impl PublicationRoot {
|
||||
pub(crate) fn open_readonly(&self, path: &Path) -> io::Result<std::fs::File> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return self.open_relative(path, rustix::fs::OFlags::RDONLY, rustix::fs::Mode::empty());
|
||||
#[cfg(windows)]
|
||||
return open_windows_readonly(path);
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
std::fs::File::open(path)
|
||||
}
|
||||
|
||||
@@ -5923,11 +5863,14 @@ impl RenameCommitGuard {
|
||||
}
|
||||
|
||||
/// Reopen a destination tree for handle-relative child publication.
|
||||
/// Every component is pinned by `open_windows_directory_component`.
|
||||
/// Ancestors remain write-exclusive while the final parent shares writes
|
||||
/// required by the kernel's relative rename. Delete sharing stays omitted
|
||||
/// throughout, so every retained directory identity remains pinned.
|
||||
fn destination_directory_guard(&self, directory: &Path, create_missing: bool) -> io::Result<RenameDestinationPathGuard> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows_sys::Wdk::Storage::FileSystem::{FILE_OPEN, FILE_OPEN_IF};
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
|
||||
|
||||
let relative = directory.strip_prefix(&self.destination_parent).map_err(|_| {
|
||||
io::Error::new(
|
||||
@@ -5960,17 +5903,35 @@ impl RenameCommitGuard {
|
||||
let parent = handles
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("Windows destination guard lost its parent handle"))?;
|
||||
handles.push(open_windows_directory_component(parent, component, FILE_OPEN)?);
|
||||
let relative_components = relative.components().filter_map(|component| match component {
|
||||
Component::Normal(component) => Some(component),
|
||||
_ => None,
|
||||
});
|
||||
for component in relative_components {
|
||||
let mut relative_components = relative
|
||||
.components()
|
||||
.filter_map(|component| match component {
|
||||
Component::Normal(component) => Some(component),
|
||||
_ => None,
|
||||
})
|
||||
.peekable();
|
||||
let destination_parent_share = if relative_components.peek().is_none() {
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE
|
||||
} else {
|
||||
FILE_SHARE_READ
|
||||
};
|
||||
handles.push(open_windows_relative_directory_component(
|
||||
parent,
|
||||
component,
|
||||
FILE_OPEN,
|
||||
destination_parent_share,
|
||||
)?);
|
||||
while let Some(component) = relative_components.next() {
|
||||
let parent = handles
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("Windows destination path guard lost its parent handle"))?;
|
||||
let disposition = if create_missing { FILE_OPEN_IF } else { FILE_OPEN };
|
||||
handles.push(open_windows_directory_component(parent, component, disposition)?);
|
||||
let share_access = if relative_components.peek().is_none() {
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE
|
||||
} else {
|
||||
FILE_SHARE_READ
|
||||
};
|
||||
handles.push(open_windows_relative_directory_component(parent, component, disposition, share_access)?);
|
||||
}
|
||||
Ok(RenameDestinationPathGuard {
|
||||
directory: directory.to_path_buf(),
|
||||
@@ -6014,47 +5975,18 @@ pub(crate) fn multipart_payload_commit_guard_std(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn multipart_payload_commit_guard_std(
|
||||
metadata_guard: &RenameCommitGuard,
|
||||
source_parent: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
) -> io::Result<RenameCommitGuard> {
|
||||
// Pin the payload staging tree like a distinct-tree metadata source; the
|
||||
// target stays the exact parent handle the metadata transaction holds.
|
||||
let source_parent_guard = lock_windows_directory_tree(source_parent, publication_root)?;
|
||||
Ok(RenameCommitGuard {
|
||||
source_parent: source_parent.to_path_buf(),
|
||||
destination_parent: metadata_guard.destination_parent.clone(),
|
||||
source_parent_guard,
|
||||
destination_parent_guard: metadata_guard.destination_parent_guard.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Native MPU metadata and payload have distinct source parents. Reuse the
|
||||
/// exact target identity already held by the metadata transaction, while
|
||||
/// independently pinning the private payload source under the same root.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) async fn multipart_payload_commit_guard(
|
||||
metadata_guard: &RenameCommitGuard,
|
||||
source_parent: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
lease: Arc<NamespaceMutationLease>,
|
||||
) -> io::Result<RenameCommitGuard> {
|
||||
let metadata_guard = metadata_guard.clone();
|
||||
let source_parent = source_parent.to_path_buf();
|
||||
#[cfg(target_os = "linux")]
|
||||
let operation = {
|
||||
// The Linux guard already carries its publication root.
|
||||
let _ = publication_root;
|
||||
move || multipart_payload_commit_guard_std(&metadata_guard, &source_parent)
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let operation = {
|
||||
let publication_root = publication_root.clone();
|
||||
move || multipart_payload_commit_guard_std(&metadata_guard, &source_parent, &publication_root)
|
||||
};
|
||||
run_blocking_namespace_operation(lease, operation).await
|
||||
run_blocking_namespace_operation(lease, move || multipart_payload_commit_guard_std(&metadata_guard, &source_parent)).await
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_rename_commit_guard(
|
||||
@@ -6096,16 +6028,20 @@ pub(crate) fn prepare_rename_commit_guard(
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// A same-directory rename reuses one guard for both sides. Every pin
|
||||
// shares writes and excludes delete; see
|
||||
// `open_windows_directory_component`.
|
||||
// A same-directory rename must use one shared-write parent handle:
|
||||
// retaining a second read-only-share handle would block the kernel's
|
||||
// relative target open. Delete sharing stays excluded, so identity is
|
||||
// still pinned. Distinct source trees remain strict.
|
||||
let same_parent = publication_root.relative_path(source_parent)? == publication_root.relative_path(destination_parent)?;
|
||||
let (source_parent_guard, destination_parent_guard) = if same_parent {
|
||||
let destination_parent_guard =
|
||||
mkdir_all_below_existing_base_std(destination_parent, destination_base, publication_root)?;
|
||||
(destination_parent_guard.clone(), destination_parent_guard)
|
||||
} else {
|
||||
let source_parent_guard = lock_windows_directory_tree(source_parent, publication_root)?;
|
||||
// The source parent also hosts private rollback staging files.
|
||||
// Their handle-relative publication needs write sharing on this
|
||||
// final directory while delete sharing remains excluded.
|
||||
let source_parent_guard = lock_windows_directory_tree(source_parent, Some(source_parent), publication_root)?;
|
||||
let destination_parent_guard =
|
||||
mkdir_all_below_existing_base_std(destination_parent, destination_base, publication_root)?;
|
||||
(source_parent_guard, destination_parent_guard)
|
||||
@@ -6125,28 +6061,6 @@ pub(crate) fn prepare_rename_commit_guard(
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix opens a directory read-only and lets the reader classify it, while
|
||||
/// Windows refuses the plain open with ACCESS_DENIED. Reopen only an actual
|
||||
/// directory with backup semantics so the same `is_dir` checks apply.
|
||||
#[cfg(windows)]
|
||||
fn open_windows_readonly(path: &Path) -> io::Result<std::fs::File> {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS;
|
||||
|
||||
match std::fs::File::open(path) {
|
||||
Err(error)
|
||||
if error.kind() == io::ErrorKind::PermissionDenied
|
||||
&& std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) =>
|
||||
{
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
|
||||
.open(path)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_windows_publication_root(path: &Path) -> io::Result<(PathBuf, WindowsDirectoryHandle)> {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
@@ -6333,12 +6247,22 @@ fn windows_file_attribute_tag(
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn lock_windows_directory_tree(path: &Path, publication_root: &PublicationRoot) -> io::Result<ExistingBaseDirectoryGuard> {
|
||||
fn lock_windows_directory_tree(
|
||||
path: &Path,
|
||||
shared_write_ancestor: Option<&Path>,
|
||||
publication_root: &PublicationRoot,
|
||||
) -> io::Result<ExistingBaseDirectoryGuard> {
|
||||
use windows_sys::Wdk::Storage::FileSystem::FILE_OPEN;
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
|
||||
|
||||
let relative = publication_root.relative_path(path)?;
|
||||
let shared_write_relative = shared_write_ancestor
|
||||
.map(|ancestor| publication_root.relative_path(ancestor))
|
||||
.transpose()?
|
||||
.filter(|ancestor| relative.starts_with(*ancestor));
|
||||
let mut handles = Vec::with_capacity(relative.components().count().saturating_add(1));
|
||||
handles.push(publication_root.directory.clone());
|
||||
let mut opened_relative = PathBuf::new();
|
||||
|
||||
for component in relative.components() {
|
||||
let Component::Normal(component) = component else {
|
||||
@@ -6353,13 +6277,26 @@ fn lock_windows_directory_tree(path: &Path, publication_root: &PublicationRoot)
|
||||
let parent = handles
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("Windows directory guard lost its root handle"))?;
|
||||
handles.push(open_windows_directory_component(parent, component, FILE_OPEN)?);
|
||||
opened_relative.push(component);
|
||||
let child = if shared_write_relative.is_some_and(|ancestor| opened_relative.as_path() == ancestor) {
|
||||
// A handle-relative rename opens the target parent for write. When
|
||||
// that parent is also a source ancestor, this source-side handle
|
||||
// must share write access or the transaction blocks itself. Delete
|
||||
// sharing remains omitted, so the directory identity stays pinned.
|
||||
open_windows_relative_directory_component(parent, component, FILE_OPEN, FILE_SHARE_READ | FILE_SHARE_WRITE)?
|
||||
} else {
|
||||
open_windows_directory_component(parent, component, FILE_OPEN)?
|
||||
};
|
||||
handles.push(child);
|
||||
}
|
||||
|
||||
Ok(ExistingBaseDirectoryGuard::new(handles))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
// SAFETY: the object attributes borrow a checked UTF-16 component and live
|
||||
// parent handle for the duration of the synchronous NtCreateFile call.
|
||||
#[allow(unsafe_code)]
|
||||
fn open_windows_relative(
|
||||
parent: &winapi_util::Handle,
|
||||
component: &std::ffi::OsStr,
|
||||
@@ -6370,64 +6307,6 @@ fn open_windows_relative(
|
||||
file_attributes: u32,
|
||||
dont_reparse: bool,
|
||||
) -> io::Result<winapi_util::Handle> {
|
||||
open_windows_relative_with_status(
|
||||
parent,
|
||||
component,
|
||||
&WindowsRelativeOpen {
|
||||
desired_access,
|
||||
share_access,
|
||||
create_disposition,
|
||||
create_options,
|
||||
file_attributes,
|
||||
dont_reparse,
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)
|
||||
}
|
||||
|
||||
/// `NtCreateFile` arguments for one handle-relative open.
|
||||
#[cfg(windows)]
|
||||
struct WindowsRelativeOpen {
|
||||
desired_access: u32,
|
||||
share_access: u32,
|
||||
create_disposition: u32,
|
||||
create_options: u32,
|
||||
file_attributes: u32,
|
||||
dont_reparse: bool,
|
||||
}
|
||||
|
||||
/// A failed relative open that keeps the NTSTATUS, which tells transient
|
||||
/// states such as a pending delete apart from errors that share its Win32 code.
|
||||
#[cfg(windows)]
|
||||
struct WindowsRelativeOpenError {
|
||||
status: Option<windows_sys::Win32::Foundation::NTSTATUS>,
|
||||
error: io::Error,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl From<io::Error> for WindowsRelativeOpenError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self { status: None, error }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
// SAFETY: the object attributes borrow a checked UTF-16 component and live
|
||||
// parent handle for the duration of the synchronous NtCreateFile call.
|
||||
#[allow(unsafe_code)]
|
||||
fn open_windows_relative_with_status(
|
||||
parent: &winapi_util::Handle,
|
||||
component: &std::ffi::OsStr,
|
||||
request: &WindowsRelativeOpen,
|
||||
) -> std::result::Result<winapi_util::Handle, WindowsRelativeOpenError> {
|
||||
let &WindowsRelativeOpen {
|
||||
desired_access,
|
||||
share_access,
|
||||
create_disposition,
|
||||
create_options,
|
||||
file_attributes,
|
||||
dont_reparse,
|
||||
} = request;
|
||||
use std::{
|
||||
mem::size_of,
|
||||
os::windows::{ffi::OsStrExt, io::AsRawHandle, io::FromRawHandle},
|
||||
@@ -6442,7 +6321,10 @@ fn open_windows_relative_with_status(
|
||||
|
||||
let mut name = component.encode_wide().collect::<Vec<_>>();
|
||||
if name.is_empty() || name.contains(&0) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "relative Windows file name is empty or contains a NUL").into());
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"relative Windows file name is empty or contains a NUL",
|
||||
));
|
||||
}
|
||||
let name_bytes = name
|
||||
.len()
|
||||
@@ -6482,17 +6364,13 @@ fn open_windows_relative_with_status(
|
||||
)
|
||||
};
|
||||
if status < 0 {
|
||||
let error = match i32::try_from(unsafe { RtlNtStatusToDosError(status) }) {
|
||||
Ok(code) => io::Error::from_raw_os_error(code),
|
||||
Err(_) => io::Error::other(format!("Windows relative open failed with NTSTATUS {status:#x}")),
|
||||
return match i32::try_from(unsafe { RtlNtStatusToDosError(status) }) {
|
||||
Ok(code) => Err(io::Error::from_raw_os_error(code)),
|
||||
Err(_) => Err(io::Error::other(format!("Windows relative open failed with NTSTATUS {status:#x}"))),
|
||||
};
|
||||
return Err(WindowsRelativeOpenError {
|
||||
status: Some(status),
|
||||
error,
|
||||
});
|
||||
}
|
||||
if handle.is_null() {
|
||||
return Err(io::Error::other("Windows relative open returned an invalid handle").into());
|
||||
return Err(io::Error::other("Windows relative open returned an invalid handle"));
|
||||
}
|
||||
|
||||
Ok(unsafe { winapi_util::Handle::from_raw_handle(handle) })
|
||||
@@ -6727,7 +6605,7 @@ pub(crate) fn compare_and_update_control_file(
|
||||
let parent = file_path
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "conditional file has no parent"))?;
|
||||
let parent_guard = lock_windows_directory_tree(parent, publication_root)?;
|
||||
let parent_guard = lock_windows_directory_tree(parent, Some(parent), publication_root)?;
|
||||
let lock = open_windows_relative(
|
||||
parent_guard.last_handle()?,
|
||||
std::ffi::OsStr::new(".rustfs-cas.lock"),
|
||||
@@ -6791,78 +6669,41 @@ pub(crate) fn compare_and_update_control_file(
|
||||
Ok(ConditionalFileUpdate::Updated)
|
||||
}
|
||||
|
||||
/// How often a directory pin retries a transient delete-access conflict.
|
||||
/// Six doubling delays from 1 ms wait about 63 ms in total, which outlasts a
|
||||
/// prune whose thread is preempted while it holds delete access.
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_DIRECTORY_PIN_RETRIES: u32 = 6;
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_DIRECTORY_PIN_FIRST_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(1);
|
||||
|
||||
/// Pin one directory component of a guarded path.
|
||||
///
|
||||
/// The handle shares reads and writes but never delete: renaming or removing
|
||||
/// a directory needs delete access to it, so omitting delete sharing pins its
|
||||
/// identity. Write sharing must stay granted on every component, ancestors
|
||||
/// included, because the kernel opens the target parent for write during a
|
||||
/// rename. A directory pinned here as one object's ancestor is also the
|
||||
/// publication parent of a nested object (`a/b` holds `a/b/c`), and a
|
||||
/// read-only share would make that unrelated rename fail with
|
||||
/// ERROR_SHARING_VIOLATION. Write sharing does not unpin an ancestor: a
|
||||
/// pinned ancestor always contains its pinned child, and NTFS sets a
|
||||
/// name-surrogate reparse point only on an empty directory.
|
||||
///
|
||||
/// Excluding delete also means the open fails while another handle holds
|
||||
/// delete access. Pruning an empty prefix directory takes that access only
|
||||
/// briefly: `RemoveDirectoryW` opens the directory for delete and closes it
|
||||
/// again, whether or not the directory turns out to be empty. A sharing
|
||||
/// violation or a pending delete is therefore retried a bounded number of
|
||||
/// times. After a removal finishes, the retry sees the directory as missing
|
||||
/// and follows `create_disposition`, so `FILE_OPEN` still reports it missing
|
||||
/// and `FILE_OPEN_IF` creates it again.
|
||||
#[cfg(windows)]
|
||||
fn open_windows_directory_component(
|
||||
parent: &WindowsDirectoryHandle,
|
||||
component: &std::ffi::OsStr,
|
||||
create_disposition: u32,
|
||||
) -> io::Result<WindowsDirectoryHandle> {
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
|
||||
|
||||
open_windows_relative_directory_component(parent, component, create_disposition, FILE_SHARE_READ)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_windows_relative_directory_component(
|
||||
parent: &WindowsDirectoryHandle,
|
||||
component: &std::ffi::OsStr,
|
||||
create_disposition: u32,
|
||||
share_access: u32,
|
||||
) -> io::Result<WindowsDirectoryHandle> {
|
||||
use windows_sys::{
|
||||
Wdk::Storage::FileSystem::{FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT},
|
||||
Win32::{
|
||||
Foundation::{STATUS_DELETE_PENDING, STATUS_SHARING_VIOLATION},
|
||||
Storage::FileSystem::{
|
||||
FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
FILE_TRAVERSE,
|
||||
},
|
||||
Win32::Storage::FileSystem::{
|
||||
FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_TRAVERSE,
|
||||
},
|
||||
};
|
||||
|
||||
let request = WindowsRelativeOpen {
|
||||
desired_access: FILE_TRAVERSE | FILE_READ_ATTRIBUTES,
|
||||
share_access: FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
let anchor = open_windows_relative(
|
||||
&parent.handle,
|
||||
component,
|
||||
FILE_TRAVERSE | FILE_READ_ATTRIBUTES,
|
||||
share_access,
|
||||
create_disposition,
|
||||
create_options: FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT,
|
||||
file_attributes: FILE_ATTRIBUTE_DIRECTORY,
|
||||
dont_reparse: true,
|
||||
};
|
||||
let mut retry_delay = WINDOWS_DIRECTORY_PIN_FIRST_RETRY_DELAY;
|
||||
let mut retries = 0;
|
||||
let anchor = loop {
|
||||
match open_windows_relative_with_status(&parent.handle, component, &request) {
|
||||
Ok(anchor) => break anchor,
|
||||
Err(WindowsRelativeOpenError {
|
||||
status: Some(STATUS_SHARING_VIOLATION | STATUS_DELETE_PENDING),
|
||||
..
|
||||
}) if retries < WINDOWS_DIRECTORY_PIN_RETRIES => {
|
||||
#[cfg(test)]
|
||||
prepare_rename_test_hooks::run_before_component_retry(component);
|
||||
std::thread::sleep(retry_delay);
|
||||
retry_delay = retry_delay.saturating_mul(2);
|
||||
retries += 1;
|
||||
}
|
||||
Err(failure) => return Err(failure.error),
|
||||
}
|
||||
};
|
||||
FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT,
|
||||
FILE_ATTRIBUTE_DIRECTORY,
|
||||
true,
|
||||
)?;
|
||||
let info = windows_file_attribute_tag(&anchor)?;
|
||||
if info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 {
|
||||
return Err(io::Error::from(io::ErrorKind::NotADirectory));
|
||||
@@ -7166,7 +7007,7 @@ pub(crate) fn pin_multipart_source_directory(
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = base;
|
||||
lock_windows_directory_tree(directory, publication_root)
|
||||
lock_windows_directory_tree(directory, Some(directory), publication_root)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
@@ -7256,31 +7097,12 @@ pub(crate) fn rename_multipart_source_owned_std(
|
||||
{
|
||||
let _ = &guard;
|
||||
let source_guard = pin_multipart_source_directory(source_parent, base, root)?;
|
||||
// The prepared Windows source holds DELETE access and shares only
|
||||
// read, so the precondition's own opens of that entry (or, for a
|
||||
// directory, of its guarded tree) would fail with a sharing violation.
|
||||
// Check it first under a non-exclusive identity anchor, then require
|
||||
// the exclusive handle to name the same file.
|
||||
#[cfg(windows)]
|
||||
let (_source_identity_anchor, expected_source_identity) = {
|
||||
let anchor = open_windows_rename_source_identity(source, &source_guard)?;
|
||||
precondition()?;
|
||||
anchor
|
||||
};
|
||||
let preparation = prepare_rename_with_retry(source, destination, base, root)?;
|
||||
#[cfg(windows)]
|
||||
if windows_file_identity(&preparation.source)? != expected_source_identity {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"rename source identity changed after its precondition",
|
||||
));
|
||||
}
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
{
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, source);
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, destination);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
precondition()?;
|
||||
#[cfg(unix)]
|
||||
validate_prepared_rename_source(&pinned_source, source)?;
|
||||
@@ -7686,15 +7508,12 @@ fn read_multipart_metadata(
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("multipart metadata has no parent"))?;
|
||||
// A read must not recreate a retired upload: pin the existing parent
|
||||
// instead of taking the write-side guard, which creates missing parents.
|
||||
existing_base_directory_suffix(parent, base)?;
|
||||
let guard = pin_multipart_source_directory(parent, base, publication_root)?;
|
||||
let guard = prepare_rename_commit_guard(parent, parent, base, publication_root)?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::other("multipart metadata has no name"))?;
|
||||
let mut file = open_windows_relative(
|
||||
guard.last_handle()?,
|
||||
guard.source_parent_guard.last_handle()?,
|
||||
name,
|
||||
SYNCHRONIZE | FILE_READ_ATTRIBUTES | FILE_READ_DATA,
|
||||
FILE_SHARE_READ,
|
||||
@@ -7870,7 +7689,7 @@ mod multipart_source_preservation_tests {
|
||||
let metadata_guard =
|
||||
prepare_rename_commit_guard(&canonical, &target, &bucket, &publication).expect("canonical metadata guard");
|
||||
let lease = acquire_namespace_mutation_lease(&target).await;
|
||||
let payload_guard = multipart_payload_commit_guard(&metadata_guard, &temporary, &publication, lease.clone())
|
||||
let payload_guard = multipart_payload_commit_guard(&metadata_guard, &temporary, lease.clone())
|
||||
.await
|
||||
.expect("distinct pinned payload source");
|
||||
assert!(
|
||||
@@ -7905,61 +7724,6 @@ mod multipart_source_preservation_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_multipart_payload_guard_publishes_distinct_source_into_metadata_target() {
|
||||
// Build paths from the configured root: the resolved handle path may
|
||||
// be verbatim, where `/` in a joined key is not a separator.
|
||||
let (root, publication, _source, _destination) = fixture();
|
||||
let root = root.path();
|
||||
let canonical = root.join("multipart/upload");
|
||||
let temporary = root.join("tmp/attempt");
|
||||
let bucket = root.join("bucket");
|
||||
let target = bucket.join("object");
|
||||
for directory in [&canonical, &temporary, &target] {
|
||||
std::fs::create_dir_all(directory).expect("two-source guard directories");
|
||||
}
|
||||
let payload = temporary.join("payload");
|
||||
std::fs::create_dir(&payload).expect("private payload DD");
|
||||
std::fs::write(payload.join("part.1"), b"native selected shard").expect("private payload shard");
|
||||
let head = canonical.join("xl.meta");
|
||||
let destination = target.join("xl.meta");
|
||||
let metadata_guard =
|
||||
prepare_rename_commit_guard(&canonical, &target, &bucket, &publication).expect("canonical metadata guard");
|
||||
let lease = acquire_namespace_mutation_lease(&target).await;
|
||||
assert!(
|
||||
rename_all_with_commit_guard(&payload, target.join("payload"), &bucket, &publication, &metadata_guard, lease.clone())
|
||||
.await
|
||||
.is_err(),
|
||||
"the metadata guard does not pin the private payload parent"
|
||||
);
|
||||
assert!(
|
||||
payload.join("part.1").exists(),
|
||||
"a rejected payload rename must leave its source in place"
|
||||
);
|
||||
let payload_guard = multipart_payload_commit_guard(&metadata_guard, &temporary, &publication, lease.clone())
|
||||
.await
|
||||
.expect("distinct pinned payload source");
|
||||
let mut prepared = create_prepared_rename_source_with_commit_guard(&head, &destination, &metadata_guard)
|
||||
.expect("canonical prepared metadata");
|
||||
prepared
|
||||
.write_all(b"prepared native metadata", true)
|
||||
.expect("durable canonical metadata");
|
||||
rename_all_with_commit_guard(&payload, target.join("payload"), &bucket, &publication, &payload_guard, lease.clone())
|
||||
.await
|
||||
.expect("payload rename uses its own pinned TMP parent");
|
||||
assert!(head.exists(), "payload publication must not consume the canonical head");
|
||||
rename_all_with_prepared_source(prepared, &head, &destination, &bucket, &publication, &metadata_guard, lease)
|
||||
.await
|
||||
.expect("metadata rename keeps its canonical parent");
|
||||
assert!(!head.exists());
|
||||
assert_eq!(std::fs::read(&destination).expect("published metadata"), b"prepared native metadata");
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("payload/part.1")).expect("published selected shard"),
|
||||
b"native selected shard"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn multipart_payload_guard_rejects_replaced_private_source_or_shared_destination() {
|
||||
@@ -7981,7 +7745,7 @@ mod multipart_source_preservation_tests {
|
||||
let metadata_guard =
|
||||
prepare_rename_commit_guard(&canonical, &target, &bucket, &publication).expect("canonical metadata guard");
|
||||
let lease = acquire_namespace_mutation_lease(&target).await;
|
||||
let payload_guard = multipart_payload_commit_guard(&metadata_guard, &temporary, &publication, lease.clone())
|
||||
let payload_guard = multipart_payload_commit_guard(&metadata_guard, &temporary, lease.clone())
|
||||
.await
|
||||
.expect("pin payload and shared target");
|
||||
let replaced = if replace_source { &temporary } else { &target };
|
||||
@@ -8238,6 +8002,7 @@ pub(crate) fn mkdir_all_below_existing_base_std(
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows_sys::Wdk::Storage::FileSystem::{FILE_OPEN, FILE_OPEN_IF};
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
|
||||
|
||||
let base_relative = publication_root.relative_path(base_dir)?;
|
||||
let capacity = base_relative
|
||||
@@ -8248,12 +8013,13 @@ pub(crate) fn mkdir_all_below_existing_base_std(
|
||||
let mut handles = Vec::with_capacity(capacity);
|
||||
handles.push(publication_root.directory.clone());
|
||||
let mut guard = ExistingBaseDirectoryGuard::new(handles);
|
||||
let components = base_relative
|
||||
let mut components = base_relative
|
||||
.components()
|
||||
.map(|component| (component, FILE_OPEN))
|
||||
.chain(relative.components().map(|component| (component, FILE_OPEN_IF)))
|
||||
.filter(|(component, _)| !matches!(component, Component::CurDir));
|
||||
for (component, disposition) in components {
|
||||
.filter(|(component, _)| !matches!(component, Component::CurDir))
|
||||
.peekable();
|
||||
while let Some((component, disposition)) = components.next() {
|
||||
let Component::Normal(component) = component else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
@@ -8264,16 +8030,22 @@ pub(crate) fn mkdir_all_below_existing_base_std(
|
||||
.handles
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("Windows publication root guard is empty"))?;
|
||||
// Every component, including the final parent the kernel opens
|
||||
// for write during a relative rename, shares writes from its first
|
||||
// open; see `open_windows_directory_component`.
|
||||
let child = open_windows_directory_component(parent, component, disposition)?;
|
||||
// The kernel opens the final parent for write during a relative
|
||||
// rename. Share writes from its first open: a temporary read-only
|
||||
// share would block another rename into the same trash directory.
|
||||
// Ancestors stay strict and no handle shares delete access, keeping
|
||||
// every retained directory identity pinned.
|
||||
let share_access = if components.peek().is_none() {
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE
|
||||
} else {
|
||||
FILE_SHARE_READ
|
||||
};
|
||||
let child = open_windows_relative_directory_component(parent, component, disposition, share_access)?;
|
||||
guard.handles.push(child);
|
||||
}
|
||||
// The hook observes the window right after the final component opened.
|
||||
#[cfg(test)]
|
||||
if guard.handles.len() > 1 {
|
||||
prepare_rename_test_hooks::run_after_component_opened(dir_path);
|
||||
#[cfg(test)]
|
||||
if components.peek().is_none() {
|
||||
prepare_rename_test_hooks::run_after_component_opened(dir_path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(guard)
|
||||
@@ -9679,7 +9451,7 @@ mod tests {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::ERROR_SHARING_VIOLATION,
|
||||
Foundation::{ERROR_SHARING_VIOLATION, GENERIC_WRITE},
|
||||
Storage::FileSystem::{
|
||||
DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ,
|
||||
FILE_SHARE_WRITE,
|
||||
@@ -9721,13 +9493,13 @@ mod tests {
|
||||
let second_preparation = prepare_rename_with_retry(&sources[1], &destinations[1], base, &publication_root)
|
||||
.expect("prepare the second trash rename");
|
||||
assert!(interleaved.load(Ordering::Acquire), "the competing parent-open window must be exercised");
|
||||
for path in [&tmp, &trash] {
|
||||
for (path, access) in [(&tmp, GENERIC_WRITE), (&trash, DELETE)] {
|
||||
let err = std::fs::OpenOptions::new()
|
||||
.access_mode(DELETE)
|
||||
.access_mode(access)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(path)
|
||||
.expect_err("pinned ancestors and final parents must exclude deletion");
|
||||
.expect_err("ancestor writes and final-parent deletion must remain excluded");
|
||||
assert_eq!(
|
||||
err.raw_os_error(),
|
||||
Some(i32::try_from(ERROR_SHARING_VIOLATION).expect("Windows error code must fit i32"))
|
||||
@@ -9744,115 +9516,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_rename_into_a_pinned_ancestor_succeeds_while_deletion_stays_excluded() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::{
|
||||
Foundation::ERROR_SHARING_VIOLATION,
|
||||
Storage::FileSystem::{
|
||||
DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ,
|
||||
FILE_SHARE_WRITE,
|
||||
},
|
||||
};
|
||||
|
||||
// `bucket/parent` is an object directory and also the prefix of the
|
||||
// nested object `bucket/parent/child`. A guard on the nested object
|
||||
// pins `parent` as an ancestor while the parent object publishes a new
|
||||
// data directory into it by pathname.
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
let base = temp_dir.path().join("bucket");
|
||||
let parent = base.join("parent");
|
||||
let child = parent.join("child");
|
||||
std::fs::create_dir_all(&child).expect("create nested object directory");
|
||||
let staged = temp_dir.path().join("staged-data-dir");
|
||||
std::fs::create_dir(&staged).expect("create staged data directory");
|
||||
std::fs::write(staged.join("part.1"), b"payload").expect("write staged part");
|
||||
let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root");
|
||||
let child_guard =
|
||||
super::mkdir_all_below_existing_base_std(&child, &base, &publication_root).expect("pin the nested object tree");
|
||||
|
||||
let destination = parent.join(uuid::Uuid::new_v4().to_string());
|
||||
std::fs::rename(&staged, &destination).expect("a pinned ancestor must accept a rename into it");
|
||||
assert_eq!(std::fs::read(destination.join("part.1")).expect("read published part"), b"payload");
|
||||
|
||||
let err = std::fs::OpenOptions::new()
|
||||
.access_mode(DELETE)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(&parent)
|
||||
.expect_err("the pinned ancestor must still refuse deletion");
|
||||
assert_eq!(
|
||||
err.raw_os_error(),
|
||||
Some(i32::try_from(ERROR_SHARING_VIOLATION).expect("Windows error code must fit i32"))
|
||||
);
|
||||
drop(child_guard);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_directory_pin_retries_a_transient_delete_holder_and_fails_closed_on_a_lasting_one() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::{
|
||||
Foundation::ERROR_SHARING_VIOLATION,
|
||||
Storage::FileSystem::{
|
||||
DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE,
|
||||
FILE_SHARE_READ, FILE_SHARE_WRITE, SYNCHRONIZE,
|
||||
},
|
||||
};
|
||||
|
||||
// Open the prefix the way `RemoveDirectoryW` does while it prunes an
|
||||
// object prefix: delete access, every share mode granted.
|
||||
let open_like_a_pruner = |path: &Path| {
|
||||
std::fs::OpenOptions::new()
|
||||
.access_mode(DELETE | SYNCHRONIZE | FILE_READ_ATTRIBUTES)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(path)
|
||||
.expect("open the prefix for delete")
|
||||
};
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
let base = temp_dir.path().join("bucket");
|
||||
let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root");
|
||||
|
||||
let transient_name = uuid::Uuid::new_v4().to_string();
|
||||
let transient_prefix = base.join(&transient_name);
|
||||
std::fs::create_dir_all(&transient_prefix).expect("create transient prefix");
|
||||
let holder = Arc::new(std::sync::Mutex::new(Some(open_like_a_pruner(&transient_prefix))));
|
||||
let holder_for_hook = Arc::clone(&holder);
|
||||
let released = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let released_for_hook = Arc::clone(&released);
|
||||
prepare_rename_test_hooks::install_before_component_retry(std::ffi::OsStr::new(&transient_name), move || {
|
||||
holder_for_hook.lock().expect("holder mutex poisoned").take();
|
||||
released_for_hook.store(true, std::sync::atomic::Ordering::Release);
|
||||
});
|
||||
let child = transient_prefix.join("child");
|
||||
let guard = super::mkdir_all_below_existing_base_std(&child, &base, &publication_root)
|
||||
.expect("a pin must outlast a transient delete holder");
|
||||
assert!(
|
||||
released.load(std::sync::atomic::Ordering::Acquire),
|
||||
"the pin must have met the delete holder"
|
||||
);
|
||||
assert!(child.is_dir(), "the retried walk must still create the child");
|
||||
let err = std::fs::OpenOptions::new()
|
||||
.access_mode(DELETE)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(&transient_prefix)
|
||||
.expect_err("the retried pin must still exclude deletion");
|
||||
assert_eq!(err.raw_os_error(), i32::try_from(ERROR_SHARING_VIOLATION).ok());
|
||||
drop(guard);
|
||||
|
||||
let lasting_prefix = base.join(uuid::Uuid::new_v4().to_string());
|
||||
std::fs::create_dir_all(&lasting_prefix).expect("create lasting prefix");
|
||||
let lasting_holder = open_like_a_pruner(&lasting_prefix);
|
||||
let Err(err) = super::mkdir_all_below_existing_base_std(&lasting_prefix.join("child"), &base, &publication_root) else {
|
||||
panic!("a pin must not wait forever on a lasting delete holder");
|
||||
};
|
||||
assert_eq!(err.raw_os_error(), i32::try_from(ERROR_SHARING_VIOLATION).ok());
|
||||
drop(lasting_holder);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_rename_all_supports_same_parent_publication() {
|
||||
@@ -10550,7 +10213,7 @@ mod tests {
|
||||
|
||||
let publication_root = test_publication_root(&[&src, &dst, &base]);
|
||||
let source_parent_guard =
|
||||
lock_windows_directory_tree(src.parent().expect("source reparse entry must have a parent"), &publication_root)
|
||||
lock_windows_directory_tree(src.parent().expect("source reparse entry must have a parent"), None, &publication_root)
|
||||
.expect("pin the source parent tree");
|
||||
let identity_error = match open_windows_rename_source_identity(&src, &source_parent_guard) {
|
||||
Ok(_) => panic!("a non-dedup source reparse entry must be rejected by its tag"),
|
||||
@@ -10584,51 +10247,30 @@ mod tests {
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_rename_all_keeps_the_source_parent_from_becoming_a_reparse_point() {
|
||||
async fn windows_rename_all_excludes_source_parent_reparse_writers() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{ERROR_DIR_NOT_EMPTY, ERROR_SHARING_VIOLATION, GENERIC_WRITE},
|
||||
Storage::FileSystem::{
|
||||
DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ,
|
||||
FILE_SHARE_WRITE,
|
||||
},
|
||||
Foundation::GENERIC_WRITE,
|
||||
Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE},
|
||||
};
|
||||
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
let base = temp_dir.path().join("bucket");
|
||||
let source_parent = temp_dir.path().join("staging");
|
||||
let outside = temp_dir.path().join("outside");
|
||||
let src = source_parent.join("staged-object");
|
||||
let dst = base.join("published-object");
|
||||
std::fs::create_dir(&base).expect("create destination base");
|
||||
std::fs::create_dir(&source_parent).expect("create source parent");
|
||||
std::fs::create_dir(&outside).expect("create outside target");
|
||||
std::fs::write(&src, b"original").expect("write original staged object");
|
||||
|
||||
let source_parent_for_hook = source_parent.clone();
|
||||
let outside_for_hook = outside.clone();
|
||||
windows_rename_test_hooks::install_before_publication(&dst, move || {
|
||||
// The pin shares writes, so a writer can open the source parent,
|
||||
// but the parent still holds the staged entry and NTFS refuses to
|
||||
// turn a non-empty directory into a mount point.
|
||||
let writable_parent = std::fs::OpenOptions::new()
|
||||
std::fs::OpenOptions::new()
|
||||
.access_mode(GENERIC_WRITE)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(&source_parent_for_hook)
|
||||
.map(winapi_util::Handle::from_file)
|
||||
.expect("a write-sharing pin admits a concurrent writer");
|
||||
let err = try_set_windows_mount_point(&writable_parent, &outside_for_hook)
|
||||
.expect_err("the pinned source parent must not become a mount point");
|
||||
assert_eq!(err.raw_os_error(), i32::try_from(ERROR_DIR_NOT_EMPTY).ok());
|
||||
|
||||
let err = std::fs::OpenOptions::new()
|
||||
.access_mode(DELETE)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(&source_parent_for_hook)
|
||||
.expect_err("the source-parent guard must exclude deletion and rename");
|
||||
assert_eq!(err.raw_os_error(), i32::try_from(ERROR_SHARING_VIOLATION).ok());
|
||||
.expect_err("the source-parent guard must exclude a reparse writer");
|
||||
});
|
||||
|
||||
rename_all(&src, &dst, &base)
|
||||
@@ -10637,7 +10279,6 @@ mod tests {
|
||||
|
||||
assert!(!src.exists(), "the original staged entry must be moved");
|
||||
assert_eq!(std::fs::read(&dst).expect("read published original object"), b"original");
|
||||
assert!(!outside.join("published-object").exists(), "publication must not escape the base");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -10717,7 +10358,7 @@ mod tests {
|
||||
let guard = mkdir_all_below_existing_base_std(&base, &base).expect("guard destination base");
|
||||
let publication_root = test_publication_root(&[&src, &dst, &base]);
|
||||
let source_parent_guard =
|
||||
lock_windows_directory_tree(src.parent().expect("source path must have a parent"), &publication_root)
|
||||
lock_windows_directory_tree(src.parent().expect("source path must have a parent"), None, &publication_root)
|
||||
.expect("anchor source parent");
|
||||
let source = open_windows_rename_source(&src, &source_parent_guard).expect("anchor source entry");
|
||||
|
||||
|
||||
@@ -269,11 +269,6 @@ pub enum StorageError {
|
||||
/// retained for operator inspection.
|
||||
#[error("delete recovery found an ambiguous legacy residue; operator inspection is required")]
|
||||
DeleteRecoveryLegacyAmbiguous,
|
||||
/// Delete recovery exhausted the per-request work budget before it could
|
||||
/// classify every residue. Retrying later makes progress, so callers treat
|
||||
/// it like `SlowDown`; it stays distinct so the disk error round-trips.
|
||||
#[error("delete recovery work limit reached; please retry")]
|
||||
DeleteRecoveryWorkLimit,
|
||||
#[error("Decommission not started")]
|
||||
DecommissionNotStarted,
|
||||
#[error("Decommission already running")]
|
||||
@@ -485,7 +480,6 @@ impl From<DiskError> for StorageError {
|
||||
DiskError::InvalidPath => StorageError::InvalidPath,
|
||||
DiskError::ConditionalDeleteConflict => StorageError::ConditionalDeleteConflict,
|
||||
DiskError::GuardedDeleteUnsupported => StorageError::GuardedDeleteUnsupported,
|
||||
DiskError::DeleteRecoveryWorkLimit => StorageError::DeleteRecoveryWorkLimit,
|
||||
DiskError::RemoteClientUnavailable(detail) => StorageError::RemoteClientUnavailable(detail),
|
||||
}
|
||||
}
|
||||
@@ -513,7 +507,6 @@ impl StorageError {
|
||||
StorageError::MethodNotAllowed => DiskError::MethodNotAllowed,
|
||||
StorageError::StorageFull => DiskError::DiskFull,
|
||||
StorageError::SlowDown => DiskError::TooManyOpenFiles,
|
||||
StorageError::DeleteRecoveryWorkLimit => DiskError::DeleteRecoveryWorkLimit,
|
||||
StorageError::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
|
||||
StorageError::InconsistentDisk => DiskError::InconsistentDisk,
|
||||
StorageError::UnsupportedDisk => DiskError::UnsupportedDisk,
|
||||
@@ -717,7 +710,6 @@ impl Clone for StorageError {
|
||||
StorageError::StorageFull => StorageError::StorageFull,
|
||||
StorageError::SlowDown => StorageError::SlowDown,
|
||||
StorageError::DeleteRecoveryLegacyAmbiguous => StorageError::DeleteRecoveryLegacyAmbiguous,
|
||||
StorageError::DeleteRecoveryWorkLimit => StorageError::DeleteRecoveryWorkLimit,
|
||||
StorageError::PrefixAccessDenied(a, b) => StorageError::PrefixAccessDenied(a.clone(), b.clone()),
|
||||
StorageError::InvalidUploadIDKeyCombination(a, b) => {
|
||||
StorageError::InvalidUploadIDKeyCombination(a.clone(), b.clone())
|
||||
@@ -860,7 +852,6 @@ impl StorageError {
|
||||
StorageError::StorageFull => StorageErrorCode::StorageFull,
|
||||
StorageError::SlowDown => StorageErrorCode::SlowDown,
|
||||
StorageError::DeleteRecoveryLegacyAmbiguous => StorageErrorCode::DeleteRecoveryLegacyAmbiguous,
|
||||
StorageError::DeleteRecoveryWorkLimit => StorageErrorCode::DeleteRecoveryWorkLimit,
|
||||
StorageError::PrefixAccessDenied(_, _) => StorageErrorCode::PrefixAccessDenied,
|
||||
StorageError::InvalidUploadIDKeyCombination(_, _) => StorageErrorCode::InvalidUploadIDKeyCombination,
|
||||
StorageError::MalformedUploadID(_) => StorageErrorCode::MalformedUploadID,
|
||||
@@ -967,7 +958,6 @@ impl StorageError {
|
||||
StorageErrorCode::StorageFull => Some(StorageError::StorageFull),
|
||||
StorageErrorCode::SlowDown => Some(StorageError::SlowDown),
|
||||
StorageErrorCode::DeleteRecoveryLegacyAmbiguous => Some(StorageError::DeleteRecoveryLegacyAmbiguous),
|
||||
StorageErrorCode::DeleteRecoveryWorkLimit => Some(StorageError::DeleteRecoveryWorkLimit),
|
||||
StorageErrorCode::PrefixAccessDenied => {
|
||||
Some(StorageError::PrefixAccessDenied(Default::default(), Default::default()))
|
||||
}
|
||||
@@ -1710,8 +1700,8 @@ mod tests {
|
||||
// covers every variant and picks up newly appended ones automatically.
|
||||
let all_variants: Vec<DiskError> = (1u32..).map_while(DiskError::from_u32).collect();
|
||||
assert!(
|
||||
all_variants.len() >= 46,
|
||||
"DiskError variant enumeration shrank: got {}, expected at least 46",
|
||||
all_variants.len() >= 42,
|
||||
"DiskError variant enumeration shrank: got {}, expected at least 42",
|
||||
all_variants.len()
|
||||
);
|
||||
|
||||
|
||||
@@ -79,14 +79,6 @@ pub fn compression_scheme_to_read_plan(scheme: &str) -> std::io::Result<(Compres
|
||||
Ok((CompressionAlgorithm::from_str(scheme)?, ReadCompressionBackend::Legacy))
|
||||
}
|
||||
|
||||
/// Validate that replicated compression metadata can be read by this build.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error for an unsupported compression scheme.
|
||||
pub fn validate_compression_scheme(scheme: &str) -> std::io::Result<()> {
|
||||
compression_scheme_to_read_plan(scheme).map(|_| ())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadEncryptionBackend {
|
||||
Legacy,
|
||||
@@ -452,12 +444,10 @@ impl WritePlan {
|
||||
|
||||
if let Some(encryption) = self.encryption {
|
||||
let etag_nonce = match encryption.mode {
|
||||
WriteEncryptionMode::Singlepart { base_nonce } => base_nonce,
|
||||
WriteEncryptionMode::SinglepartObjectKey => [0u8; 12],
|
||||
// Each part write needs an independent fingerprint, including a
|
||||
// retry at the same part number. This salt affects only the ETag
|
||||
// MAC; encryption framing and its data nonce stay unchanged.
|
||||
WriteEncryptionMode::MultipartLegacy { .. } | WriteEncryptionMode::MultipartObjectKey { .. } => rand::random(),
|
||||
WriteEncryptionMode::Singlepart { base_nonce } | WriteEncryptionMode::MultipartLegacy { base_nonce, .. } => {
|
||||
base_nonce
|
||||
}
|
||||
WriteEncryptionMode::SinglepartObjectKey | WriteEncryptionMode::MultipartObjectKey { .. } => [0u8; 12],
|
||||
};
|
||||
let protected_reader = rustfs_rio::EtagReader::new_encrypted(reader, encryption.key_bytes, etag_nonce)?
|
||||
.with_inner_etag_delegation(delegate_inner_etag);
|
||||
@@ -568,54 +558,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_etags_are_unique_per_write_without_changing_data_encryption() {
|
||||
let plaintext = b"same confidential part";
|
||||
let size = i64::try_from(plaintext.len()).expect("test length");
|
||||
for object_key_mode in [false, true] {
|
||||
let mut tags = std::collections::HashSet::new();
|
||||
for part_number in [1, 2, 1] {
|
||||
let encryption = if object_key_mode {
|
||||
WriteEncryption::multipart_object_key([7; 32], part_number)
|
||||
} else {
|
||||
WriteEncryption::multipart([7; 32], [9; 12], part_number as usize)
|
||||
};
|
||||
let source = HashReader::from_stream(Cursor::new(plaintext), size, size, None, None, false).expect("plaintext");
|
||||
let mut reader = WritePlan::new()
|
||||
.with_encryption(encryption)
|
||||
.apply(source, size)
|
||||
.expect("part plan");
|
||||
let mut ciphertext = Vec::new();
|
||||
reader.read_to_end(&mut ciphertext).await.expect("encrypt part");
|
||||
let tag = reader.try_resolve_etag().expect("part fingerprint");
|
||||
assert_eq!(reader.try_resolve_etag().as_ref(), Some(&tag), "reading a stored result stays stable");
|
||||
assert!(
|
||||
tags.insert(tag),
|
||||
"different part numbers and retries must not identify matching plaintext"
|
||||
);
|
||||
#[cfg(feature = "rio-v2")]
|
||||
let mut decrypt = if object_key_mode {
|
||||
DecryptReader::new_multipart_with_object_key(Cursor::new(ciphertext), [7; 32], vec![part_number as usize])
|
||||
} else {
|
||||
DecryptReader::new_multipart(Cursor::new(ciphertext), [7; 32], [9; 12], vec![part_number as usize])
|
||||
};
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
let mut decrypt = DecryptReader::new_multipart(
|
||||
Cursor::new(ciphertext),
|
||||
[7; 32],
|
||||
if object_key_mode { [0; 12] } else { [9; 12] },
|
||||
vec![part_number as usize],
|
||||
);
|
||||
let mut decoded = Vec::new();
|
||||
decrypt
|
||||
.read_to_end(&mut decoded)
|
||||
.await
|
||||
.expect("existing data nonce still decrypts");
|
||||
assert_eq!(decoded, plaintext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_non_trailing_checksum_survives(plan: WritePlan) {
|
||||
let plaintext = b"checksum-context-through-write-plan".repeat(256);
|
||||
let actual_size = plaintext.len() as i64;
|
||||
|
||||
@@ -949,82 +949,8 @@ pub enum ShardIntegrityWriteMode {
|
||||
Protected,
|
||||
}
|
||||
|
||||
/// Shared foreground recovery allowance. Clones of ObjectOptions retain one request budget.
|
||||
/// Credits cover directory entries (including a minimum charge for an empty probe) and
|
||||
/// transaction inspections. Each bounded call is admitted while credits remain and is
|
||||
/// charged for the entries it visited when it completes.
|
||||
#[derive(Debug)]
|
||||
pub struct DeleteRecoveryWorkBudget {
|
||||
remaining: std::sync::atomic::AtomicU32,
|
||||
}
|
||||
|
||||
/// Entries one disk may visit per recovery step (discovery plus inspection).
|
||||
/// Every disk of every set a request fans out to is admitted up to this much
|
||||
/// and charged on completion, so a budget must cover that many disks.
|
||||
const DELETE_RECOVERY_RESERVATION_PER_DISK: u32 = 4096 + 65_536;
|
||||
|
||||
impl Default for DeleteRecoveryWorkBudget {
|
||||
/// Covers a full 16-drive set with headroom; `for_disks` scales it when a
|
||||
/// request spans more drives than that.
|
||||
fn default() -> Self {
|
||||
Self::new(16 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteRecoveryWorkBudget {
|
||||
/// A per-request budget sized for `disks` concurrently admitted recovery steps.
|
||||
pub(crate) fn for_disks(disks: usize) -> Self {
|
||||
let per_disk = u64::from(DELETE_RECOVERY_RESERVATION_PER_DISK) * 2;
|
||||
let scaled = u64::try_from(disks).unwrap_or(u64::MAX).saturating_mul(per_disk);
|
||||
let default = u64::from(Self::default().remaining());
|
||||
Self::new(u32::try_from(scaled.max(default)).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
pub(crate) fn new(entries: u32) -> Self {
|
||||
Self {
|
||||
remaining: std::sync::atomic::AtomicU32::new(entries),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reserve(&self, maximum: u32) -> crate::disk::error::Result<u32> {
|
||||
use std::sync::atomic::Ordering;
|
||||
self.remaining
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
|
||||
(remaining > 0).then(|| remaining.saturating_sub(maximum))
|
||||
})
|
||||
.map(|remaining| remaining.min(maximum))
|
||||
.map_err(|_| crate::disk::error::DiskError::DeleteRecoveryWorkLimit)
|
||||
}
|
||||
|
||||
pub(crate) fn remaining(&self) -> u32 {
|
||||
self.remaining.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Admit one bounded disk call: refuse once the budget is spent, otherwise
|
||||
/// cap the call at `maximum` or what remains. The work actually performed is
|
||||
/// charged on completion with `charge`, so concurrent calls never exhaust
|
||||
/// the budget by reservation alone (a bulk delete fans out dozens of
|
||||
/// inspections per key); each call is still bounded by its own cap.
|
||||
pub(crate) fn admit(&self, maximum: u32) -> crate::disk::error::Result<u32> {
|
||||
match self.remaining() {
|
||||
0 => Err(crate::disk::error::DiskError::DeleteRecoveryWorkLimit),
|
||||
remaining => Ok(remaining.min(maximum)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the entries a bounded call visited (at least one per call).
|
||||
pub(crate) fn charge(&self, visited: u32) {
|
||||
use std::sync::atomic::Ordering;
|
||||
let _ = self.remaining.try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
|
||||
Some(remaining.saturating_sub(visited.max(1)))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct ObjectOptions {
|
||||
#[doc(hidden)]
|
||||
pub delete_recovery_work_budget: Option<Arc<DeleteRecoveryWorkBudget>>,
|
||||
// Use the maximum parity (N/2), used when saving server configuration files
|
||||
pub max_parity: bool,
|
||||
pub mod_time: Option<OffsetDateTime>,
|
||||
@@ -1176,12 +1102,6 @@ pub struct ObjectOptions {
|
||||
pub expiration: ExpirationOptions,
|
||||
pub lifecycle_audit_event: LcAuditEvent,
|
||||
|
||||
/// In-process request gate evaluated on authoritative completion metadata.
|
||||
/// Not persisted or sent over RPC. The trait deliberately has no Debug bound:
|
||||
/// implementations may own customer encryption keys.
|
||||
#[doc(hidden)]
|
||||
pub multipart_completion_authorization:
|
||||
Option<Arc<dyn crate::storage_api_contracts::multipart::MultipartCompletionAuthorization<Error = Error>>>,
|
||||
pub eval_metadata: Option<HashMap<String, String>>,
|
||||
/// Internal compare-and-set condition for replication workers publishing
|
||||
/// terminal status after remote I/O. Storage validates it while holding
|
||||
@@ -1195,10 +1115,6 @@ pub struct ObjectOptions {
|
||||
/// Authoritative bucket Object Lock snapshot installed inside `ECStore`
|
||||
/// before a destructive commit reaches the set layer.
|
||||
pub object_lock_config_snapshot: Option<Arc<ObjectLockConfigSnapshot>>,
|
||||
/// ECStore single PUTs acquire their destructive-write configuration guards
|
||||
/// only after staging the client body. External lock owners supply a snapshot.
|
||||
#[doc(hidden)]
|
||||
pub defer_put_object_lock_snapshot: bool,
|
||||
|
||||
pub want_checksum: Option<Checksum>,
|
||||
pub skip_verify_bitrot: bool,
|
||||
@@ -1230,11 +1146,6 @@ pub enum ReplicationStatusWritebackMode {
|
||||
}
|
||||
|
||||
impl ObjectOptions {
|
||||
pub(crate) fn ensure_delete_recovery_work_budget(&mut self) {
|
||||
self.delete_recovery_work_budget
|
||||
.get_or_insert_with(|| Arc::new(DeleteRecoveryWorkBudget::default()));
|
||||
}
|
||||
|
||||
pub(crate) fn shard_integrity_write_enabled(&self) -> bool {
|
||||
match self.shard_integrity_write_mode {
|
||||
Some(ShardIntegrityWriteMode::Protected) => true,
|
||||
|
||||
@@ -1910,10 +1910,6 @@ mod tests {
|
||||
&ObjectOptions {
|
||||
version_id: Some(version_id.to_string()),
|
||||
mod_time: earlier(version.mod_time),
|
||||
// Without the object lock the rename waits for every
|
||||
// drive, so no early-ACK tail is still writing the copies
|
||||
// removed below.
|
||||
no_lock: true,
|
||||
..versioned
|
||||
},
|
||||
))
|
||||
|
||||
@@ -786,7 +786,7 @@ impl WarmBackend for MockWarmBackend {
|
||||
bytes.len()
|
||||
};
|
||||
|
||||
Ok(ReadCloser::new(Cursor::new(bytes[start.min(bytes.len())..end].to_vec())))
|
||||
Ok(tokio::io::BufReader::new(Cursor::new(bytes[start.min(bytes.len())..end].to_vec())))
|
||||
}
|
||||
|
||||
async fn remove(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
|
||||
|
||||
@@ -37,6 +37,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::{
|
||||
select,
|
||||
sync::{Notify, RwLock},
|
||||
@@ -8182,7 +8183,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn get(&self, _object: &str, _rv: &str, _opts: WarmBackendGetOpts) -> io::Result<ReadCloser> {
|
||||
Ok(ReadCloser::new(Cursor::new(b"RustFS".to_vec())))
|
||||
Ok(BufReader::new(Cursor::new(b"RustFS".to_vec())))
|
||||
}
|
||||
|
||||
async fn remove(&self, _object: &str, _rv: &str) -> io::Result<()> {
|
||||
@@ -8247,7 +8248,7 @@ mod tests {
|
||||
_opts: WarmBackendGetOpts,
|
||||
) -> std::result::Result<ReadCloser, std::io::Error> {
|
||||
if self.healthy {
|
||||
Ok(ReadCloser::new(Cursor::new(b"RustFS".to_vec())))
|
||||
Ok(BufReader::new(Cursor::new(b"RustFS".to_vec())))
|
||||
} else {
|
||||
Err(std::io::Error::other("mock get failed"))
|
||||
}
|
||||
@@ -9894,7 +9895,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn get(&self, _object: &str, _rv: &str, _opts: WarmBackendGetOpts) -> io::Result<ReadCloser> {
|
||||
Ok(ReadCloser::new(Cursor::new(b"RustFS".to_vec())))
|
||||
Ok(BufReader::new(Cursor::new(b"RustFS".to_vec())))
|
||||
}
|
||||
|
||||
async fn remove(&self, _object: &str, _rv: &str) -> io::Result<()> {
|
||||
|
||||
@@ -195,14 +195,7 @@ impl SetDisks {
|
||||
metadata.add_version(selected)?;
|
||||
}
|
||||
if metadata.versions.is_empty() {
|
||||
// With no decodable version, the per-version quorum loop cannot
|
||||
// reject corrupt or unavailable replicas. Only complete, readable
|
||||
// evidence may establish that this key has no authoritative version.
|
||||
return if complete {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(DiskError::ErasureReadQuorum)
|
||||
};
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(MetaCacheEntry {
|
||||
name,
|
||||
|
||||
@@ -357,7 +357,8 @@ use crate::io_support::rio::HashReader;
|
||||
|
||||
pub const DEFAULT_READ_BUFFER_SIZE: usize = MI_B; // 1 MiB = 1024 * 1024;
|
||||
pub const MAX_PARTS_COUNT: usize = 10000;
|
||||
pub(crate) use crate::disk::multipart_index::{RUSTFS_MULTIPART_BUCKET_KEY, RUSTFS_MULTIPART_OBJECT_KEY};
|
||||
pub(crate) const RUSTFS_MULTIPART_BUCKET_KEY: &str = "x-rustfs-internal-multipart-bucket";
|
||||
pub(crate) const RUSTFS_MULTIPART_OBJECT_KEY: &str = "x-rustfs-internal-multipart-object";
|
||||
pub(crate) const DATA_MOVEMENT_MULTIPART_PREFIX: &str = "data-movement";
|
||||
const ENV_ISSUE3031_DIAG_ENABLE: &str = "RUSTFS_ISSUE3031_DIAG_ENABLE";
|
||||
|
||||
|
||||
@@ -141,7 +141,6 @@ pub(crate) struct DeleteRecoverySummary {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DeleteRecoveryContext<'a> {
|
||||
pub incarnation: Option<Uuid>,
|
||||
pub work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
|
||||
pub dry_run: bool,
|
||||
pub explicit_delete: bool,
|
||||
/// Only the locked heal path may repair a live marker-creation source.
|
||||
@@ -157,7 +156,6 @@ impl<'a> DeleteRecoveryContext<'a> {
|
||||
pub(crate) fn new(incarnation: Option<Uuid>, can_commit: &'a (dyn Fn() -> bool + Sync)) -> Self {
|
||||
Self {
|
||||
incarnation,
|
||||
work_budget: None,
|
||||
dry_run: false,
|
||||
explicit_delete: false,
|
||||
repair_marker_source: false,
|
||||
@@ -232,54 +230,19 @@ async fn inspect(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
budget: Option<&crate::object_api::DeleteRecoveryWorkBudget>,
|
||||
) -> Vec<Result<DeleteRecoveryInspection>> {
|
||||
join_all(disks.iter().map(|disk| async move {
|
||||
let disk = disk.as_ref().ok_or(DiskError::DiskNotFound)?;
|
||||
let state = if let Some(budget) = budget {
|
||||
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES)?;
|
||||
let reply = disk
|
||||
.inspect_delete_transaction_bounded(bucket, object, transaction, admitted)
|
||||
.await?;
|
||||
if reply.visited > admitted {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
budget.charge(reply.visited);
|
||||
reply.inspection
|
||||
} else {
|
||||
disk.inspect_delete_transaction(bucket, object, transaction).await?
|
||||
};
|
||||
let state = disk
|
||||
.as_ref()
|
||||
.ok_or(DiskError::DiskNotFound)?
|
||||
.inspect_delete_transaction(bucket, object, transaction)
|
||||
.await?;
|
||||
validate(&state, object, transaction)?;
|
||||
Ok(state)
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn discover_bounded(
|
||||
disk: &DiskStore,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
budget: &crate::object_api::DeleteRecoveryWorkBudget,
|
||||
) -> Result<Vec<DeleteTransactionId>> {
|
||||
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES)?;
|
||||
let page = disk.discover_delete_transactions_bounded(bucket, object, admitted).await?;
|
||||
if page.visited > admitted
|
||||
|| page.transactions.len() > 256
|
||||
|| page
|
||||
.transactions
|
||||
.iter()
|
||||
.any(|id| id.object != object || id.transaction.is_nil())
|
||||
|| (!page.complete && !page.transactions.is_empty())
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
budget.charge(page.visited);
|
||||
if !page.complete {
|
||||
return Err(DiskError::DeleteRecoveryWorkLimit);
|
||||
}
|
||||
Ok(page.transactions)
|
||||
}
|
||||
|
||||
async fn discover(disk: &DiskStore, bucket: &str, object: Option<&str>) -> Result<Vec<DeleteTransactionId>> {
|
||||
let mut cursor = None;
|
||||
let mut ids = BTreeSet::new();
|
||||
@@ -310,92 +273,30 @@ async fn discover(disk: &DiskStore, bucket: &str, object: Option<&str>) -> Resul
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RecoveryWorkScope<'a> {
|
||||
namespace_owner: Option<&'a Arc<dyn Send + Sync>>,
|
||||
budget: Option<&'a crate::object_api::DeleteRecoveryWorkBudget>,
|
||||
}
|
||||
|
||||
async fn settle_outcome(
|
||||
disk: &DiskStore,
|
||||
bucket: &str,
|
||||
state: &DeleteRecoveryInspection,
|
||||
action: DeleteRecoveryAction,
|
||||
can_commit: &(impl Fn() -> bool + Sync + ?Sized),
|
||||
scope: RecoveryWorkScope<'_>,
|
||||
) -> Result<crate::disk::DeleteRecoverySettlementOutcome> {
|
||||
use crate::disk::DeleteRecoverySettlementOutcome;
|
||||
if !can_commit() {
|
||||
return Err(DiskError::other("delete recovery namespace fence was lost"));
|
||||
}
|
||||
let outcome = if let Some(budget) = scope.budget {
|
||||
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES)?;
|
||||
let may_publish = matches!(&action, DeleteRecoveryAction::PublishCommit | DeleteRecoveryAction::AdoptCommit { .. });
|
||||
let reply = disk
|
||||
.settle_delete_transaction_bounded(
|
||||
bucket,
|
||||
crate::disk::BoundedDeleteRecoverySettlement {
|
||||
object: state.id.object.clone(),
|
||||
transaction: state.id.transaction,
|
||||
expected_fingerprint: state.fingerprint,
|
||||
action,
|
||||
max_entries: admitted,
|
||||
},
|
||||
scope.namespace_owner.cloned(),
|
||||
)
|
||||
.await?;
|
||||
if reply.visited > admitted
|
||||
|| (!may_publish && matches!(&reply.outcome, DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred))
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
budget.charge(reply.visited);
|
||||
reply.outcome
|
||||
} else {
|
||||
DeleteRecoverySettlementOutcome::Inspected(Box::new(
|
||||
disk.settle_delete_transaction_with_namespace_owner(
|
||||
bucket,
|
||||
&state.id.object,
|
||||
state.id.transaction,
|
||||
state.fingerprint,
|
||||
action,
|
||||
scope.namespace_owner.cloned(),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
};
|
||||
if let DeleteRecoverySettlementOutcome::Inspected(next) = &outcome {
|
||||
validate(next, &state.id.object, state.id.transaction)?;
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn settle(
|
||||
disk: &DiskStore,
|
||||
bucket: &str,
|
||||
state: &DeleteRecoveryInspection,
|
||||
action: DeleteRecoveryAction,
|
||||
can_commit: &(impl Fn() -> bool + Sync + ?Sized),
|
||||
scope: RecoveryWorkScope<'_>,
|
||||
namespace_owner: Option<&Arc<dyn Send + Sync>>,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
match settle_outcome(disk, bucket, state, action, can_commit, scope).await? {
|
||||
crate::disk::DeleteRecoverySettlementOutcome::Inspected(inspection) => Ok(*inspection),
|
||||
crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred => {
|
||||
Err(DiskError::DeleteRecoveryWorkLimit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_receipt_quorum(outcomes: &[Result<crate::disk::DeleteRecoverySettlementOutcome>], quorum: usize) -> bool {
|
||||
outcomes.iter().filter(|outcome| published_receipt(outcome)).count() >= quorum
|
||||
}
|
||||
|
||||
fn published_receipt(outcome: &Result<crate::disk::DeleteRecoverySettlementOutcome>) -> bool {
|
||||
match outcome {
|
||||
Ok(crate::disk::DeleteRecoverySettlementOutcome::Inspected(state)) => receipt(state.state),
|
||||
Ok(crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred) => true,
|
||||
Err(_) => false,
|
||||
if !can_commit() {
|
||||
return Err(DiskError::other("delete recovery namespace fence was lost"));
|
||||
}
|
||||
let result = disk
|
||||
.settle_delete_transaction_with_namespace_owner(
|
||||
bucket,
|
||||
&state.id.object,
|
||||
state.id.transaction,
|
||||
state.fingerprint,
|
||||
action,
|
||||
namespace_owner.cloned(),
|
||||
)
|
||||
.await;
|
||||
let next = result?;
|
||||
validate(&next, &state.id.object, state.id.transaction)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Each phase fans out across independent drives, and awaits all started work
|
||||
@@ -406,16 +307,23 @@ async fn settle_group(
|
||||
copies: &mut [DeleteRecoveryInspection],
|
||||
action: impl Fn(&DeleteRecoveryInspection) -> Option<DeleteRecoveryAction> + Sync,
|
||||
can_commit: &(impl Fn() -> bool + Sync + ?Sized),
|
||||
scope: RecoveryWorkScope<'_>,
|
||||
namespace_owner: Option<&Arc<dyn Send + Sync>>,
|
||||
removed_versions: &mut HashSet<Uuid>,
|
||||
) -> Result<bool> {
|
||||
let results = join_all(disks.iter().zip(copies.iter()).map(|(disk, copy)| {
|
||||
let action = action(copy);
|
||||
async move {
|
||||
match action {
|
||||
Some(action) => settle(disk.as_ref().ok_or(DiskError::DiskNotFound)?, bucket, copy, action, can_commit, scope)
|
||||
.await
|
||||
.map(Some),
|
||||
Some(action) => settle(
|
||||
disk.as_ref().ok_or(DiskError::DiskNotFound)?,
|
||||
bucket,
|
||||
copy,
|
||||
action,
|
||||
can_commit,
|
||||
namespace_owner,
|
||||
)
|
||||
.await
|
||||
.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -531,31 +439,16 @@ impl SetDisks {
|
||||
true
|
||||
}
|
||||
};
|
||||
let mut fast_receipt_quorum = false;
|
||||
if can_try_local_fast_path && can_commit() {
|
||||
let fast_published = join_all(disks.iter().map(|disk| async {
|
||||
if !can_commit() {
|
||||
return Err(DiskError::other("delete recovery namespace fence was lost"));
|
||||
}
|
||||
let disk = disk.as_ref().ok_or(DiskError::DiskNotFound)?;
|
||||
disk.try_publish_prepared_delete_local(
|
||||
bucket,
|
||||
object,
|
||||
transaction,
|
||||
context.namespace_owner.clone(),
|
||||
context.work_budget.clone(),
|
||||
)
|
||||
.await
|
||||
disk.try_publish_prepared_delete_local(bucket, object, transaction, context.namespace_owner.clone())
|
||||
.await
|
||||
}))
|
||||
.await;
|
||||
fast_receipt_quorum = fast_published.iter().filter(|result| matches!(result, Ok(Some(_)))).count() >= write_quorum;
|
||||
if fast_receipt_quorum
|
||||
&& fast_published
|
||||
.iter()
|
||||
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let all_fast_published = fast_published.len() == self.set_drive_count
|
||||
&& fast_published.iter().all(|res| matches!(res, Ok(Some((_, true, true)))));
|
||||
if all_fast_published && can_commit() {
|
||||
@@ -563,7 +456,6 @@ impl SetDisks {
|
||||
test_hooks::record_cleanup_attempt(bucket, object);
|
||||
let finalized = join_all(disks.iter().zip(fast_published.iter()).map(|(disk, pub_res)| {
|
||||
let ns_owner = context.namespace_owner.clone();
|
||||
let work_budget = context.work_budget.clone();
|
||||
async move {
|
||||
if !can_commit() {
|
||||
return Ok(false);
|
||||
@@ -574,24 +466,11 @@ impl SetDisks {
|
||||
let Ok(Some((journal, _, _))) = pub_res else {
|
||||
return Ok(false);
|
||||
};
|
||||
disk.try_finish_and_finalize_prepared_delete_local(
|
||||
bucket,
|
||||
object,
|
||||
transaction,
|
||||
journal,
|
||||
ns_owner,
|
||||
work_budget,
|
||||
)
|
||||
.await
|
||||
disk.try_finish_and_finalize_prepared_delete_local(bucket, object, transaction, journal, ns_owner)
|
||||
.await
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
if finalized
|
||||
.iter()
|
||||
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if finalized.iter().all(|res| matches!(res, Ok(true))) {
|
||||
if let Some(ticket) = context.recovery_ticket.as_ref() {
|
||||
self.ctx.delete_recovery_hints().ack(ticket);
|
||||
@@ -600,17 +479,7 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
}
|
||||
let states = inspect(disks, bucket, object, transaction, context.work_budget.as_deref()).await;
|
||||
if states
|
||||
.iter()
|
||||
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
|
||||
{
|
||||
return if fast_receipt_quorum {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DiskError::DeleteRecoveryWorkLimit)
|
||||
};
|
||||
}
|
||||
let states = inspect(disks, bucket, object, transaction).await;
|
||||
let untouched_absence = |state: &Result<DeleteRecoveryInspection>| matches!(state, Ok(state) if state.state == DeleteRecoveryState::Absent && state.current_digest.is_none());
|
||||
if states.iter().filter(|state| untouched_absence(state)).count() >= write_quorum
|
||||
&& states.iter().all(|state| {
|
||||
@@ -630,50 +499,25 @@ impl SetDisks {
|
||||
if state.state == DeleteRecoveryState::LegacyAmbiguous {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
settle_outcome(
|
||||
settle(
|
||||
disk,
|
||||
bucket,
|
||||
state,
|
||||
DeleteRecoveryAction::PublishCommit,
|
||||
can_commit,
|
||||
RecoveryWorkScope {
|
||||
namespace_owner: context.namespace_owner.as_ref(),
|
||||
budget: context.work_budget.as_deref(),
|
||||
},
|
||||
context.namespace_owner.as_ref(),
|
||||
)
|
||||
.await
|
||||
}))
|
||||
.await;
|
||||
if !has_receipt_quorum(&published, write_quorum) {
|
||||
return Err(
|
||||
if published
|
||||
.iter()
|
||||
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
|
||||
{
|
||||
DiskError::DeleteRecoveryWorkLimit
|
||||
} else {
|
||||
DiskError::ErasureWriteQuorum
|
||||
},
|
||||
);
|
||||
if published
|
||||
.iter()
|
||||
.filter(|state| matches!(state, Ok(state) if receipt(state.state)))
|
||||
.count()
|
||||
< write_quorum
|
||||
{
|
||||
return Err(DiskError::ErasureWriteQuorum);
|
||||
}
|
||||
if published.iter().any(|outcome| {
|
||||
matches!(
|
||||
outcome,
|
||||
Ok(crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred)
|
||||
)
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
let published = published
|
||||
.into_iter()
|
||||
.map(|outcome| match outcome {
|
||||
Ok(crate::disk::DeleteRecoverySettlementOutcome::Inspected(state)) => Ok(*state),
|
||||
Ok(crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred) => {
|
||||
Err(DiskError::DeleteRecoveryWorkLimit)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// Physical cleanup is retryable after the certified decision. An
|
||||
// unavailable participant retains the decision for its eventual return.
|
||||
let ticket = context.recovery_ticket.clone();
|
||||
@@ -826,7 +670,6 @@ impl SetDisks {
|
||||
}
|
||||
let DeleteRecoveryContext {
|
||||
incarnation,
|
||||
work_budget,
|
||||
dry_run,
|
||||
explicit_delete,
|
||||
repair_marker_source,
|
||||
@@ -838,13 +681,7 @@ impl SetDisks {
|
||||
} = context;
|
||||
let disks = self.disk_inventory().await;
|
||||
let complete_inventory = disks.len() == self.set_drive_count && disks.iter().all(Option::is_some);
|
||||
let pages = join_all(disks.iter().flatten().map(|disk| async {
|
||||
match work_budget.as_deref() {
|
||||
Some(budget) => discover_bounded(disk, bucket, object, budget).await,
|
||||
None => discover(disk, bucket, Some(object)).await,
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let pages = join_all(disks.iter().flatten().map(|disk| discover(disk, bucket, Some(object)))).await;
|
||||
let mut transactions = BTreeSet::new();
|
||||
let mut inspected = 0;
|
||||
let mut offline = !complete_inventory;
|
||||
@@ -875,7 +712,6 @@ impl SetDisks {
|
||||
..Default::default()
|
||||
};
|
||||
let mut all_states = BTreeMap::new();
|
||||
let mut registered_evidence_hint = false;
|
||||
for transaction in transactions {
|
||||
let mut complete = Vec::with_capacity(disks.len());
|
||||
let inspections = if let Some((published_tx, published_states)) = published_hint
|
||||
@@ -887,14 +723,8 @@ impl SetDisks {
|
||||
{
|
||||
published_states.to_vec()
|
||||
} else {
|
||||
inspect(&disks, bucket, object, transaction, work_budget.as_deref()).await
|
||||
inspect(&disks, bucket, object, transaction).await
|
||||
};
|
||||
if inspections
|
||||
.iter()
|
||||
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
|
||||
{
|
||||
return Err(DiskError::DeleteRecoveryWorkLimit);
|
||||
}
|
||||
for state in inspections {
|
||||
match state {
|
||||
Ok(state) => {
|
||||
@@ -906,33 +736,6 @@ impl SetDisks {
|
||||
summary.blocked += 1;
|
||||
return Ok(summary);
|
||||
}
|
||||
// Candidate UUID directories are not evidence. Only a bound, inspected
|
||||
// journal can create a foreground hint; empty/partial scans rely on the
|
||||
// existing raw anti-entropy obligation instead of queuing arbitrary prefixes.
|
||||
if work_budget.is_some()
|
||||
&& !registered_evidence_hint
|
||||
&& journal.protocol_version == DELETE_RECOVERY_PROTOCOL_VERSION
|
||||
&& journal.volume == bucket
|
||||
&& journal.object == object
|
||||
&& journal.transaction == transaction
|
||||
&& matches!(
|
||||
state.state,
|
||||
DeleteRecoveryState::NewPrepared
|
||||
| DeleteRecoveryState::QuorumCommitted
|
||||
| DeleteRecoveryState::CommitFinished
|
||||
)
|
||||
{
|
||||
self.ctx.delete_recovery_hints().register(
|
||||
crate::store::delete_recovery_runtime::DeleteRecoveryKey {
|
||||
pool: self.pool_index,
|
||||
set: self.set_index,
|
||||
bucket: bucket.to_owned(),
|
||||
object: object.to_owned(),
|
||||
incarnation,
|
||||
},
|
||||
);
|
||||
registered_evidence_hint = true;
|
||||
}
|
||||
}
|
||||
complete.push(state);
|
||||
}
|
||||
@@ -964,7 +767,7 @@ impl SetDisks {
|
||||
};
|
||||
if let Some((transaction, source)) = prerequisite {
|
||||
Box::pin(self.heal_delete_recovery_source(bucket, object, &source, can_commit, namespace_owner.as_ref())).await?;
|
||||
let refreshed = inspect(&disks, bucket, object, transaction, work_budget.as_deref())
|
||||
let refreshed = inspect(&disks, bucket, object, transaction)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
@@ -1050,7 +853,7 @@ impl SetDisks {
|
||||
if mutated {
|
||||
// A sibling may have restored metadata or reclaimed shared
|
||||
// evidence. Refresh fingerprints before acting on this group.
|
||||
*copies = inspect(&disks, bucket, object, transaction, work_budget.as_deref())
|
||||
*copies = inspect(&disks, bucket, object, transaction)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
@@ -1073,10 +876,7 @@ impl SetDisks {
|
||||
(copy.state == DeleteRecoveryState::LegacyAmbiguous).then_some(DeleteRecoveryAction::DiscardLegacy)
|
||||
},
|
||||
can_commit,
|
||||
RecoveryWorkScope {
|
||||
namespace_owner: namespace_owner.as_ref(),
|
||||
budget: work_budget.as_deref(),
|
||||
},
|
||||
namespace_owner.as_ref(),
|
||||
&mut summary.removed_versions,
|
||||
)
|
||||
.await?
|
||||
@@ -1187,10 +987,7 @@ impl SetDisks {
|
||||
)
|
||||
},
|
||||
can_commit,
|
||||
RecoveryWorkScope {
|
||||
namespace_owner: namespace_owner.as_ref(),
|
||||
budget: work_budget.as_deref(),
|
||||
},
|
||||
namespace_owner.as_ref(),
|
||||
&mut summary.removed_versions,
|
||||
)
|
||||
.await?;
|
||||
@@ -1204,10 +1001,7 @@ impl SetDisks {
|
||||
.then_some(DeleteRecoveryAction::FinishCommit)
|
||||
},
|
||||
can_commit,
|
||||
RecoveryWorkScope {
|
||||
namespace_owner: namespace_owner.as_ref(),
|
||||
budget: work_budget.as_deref(),
|
||||
},
|
||||
namespace_owner.as_ref(),
|
||||
&mut summary.removed_versions,
|
||||
)
|
||||
.await?;
|
||||
@@ -1254,10 +1048,7 @@ impl SetDisks {
|
||||
copies,
|
||||
|copy| (copy.state == DeleteRecoveryState::NewPrepared).then_some(DeleteRecoveryAction::Rollback),
|
||||
can_commit,
|
||||
RecoveryWorkScope {
|
||||
namespace_owner: namespace_owner.as_ref(),
|
||||
budget: work_budget.as_deref(),
|
||||
},
|
||||
namespace_owner.as_ref(),
|
||||
&mut summary.removed_versions,
|
||||
)
|
||||
.await?
|
||||
@@ -1285,7 +1076,7 @@ impl SetDisks {
|
||||
settled.push(copies);
|
||||
} else {
|
||||
for transaction in all_states.keys() {
|
||||
let fresh = inspect(&disks, bucket, object, *transaction, work_budget.as_deref()).await;
|
||||
let fresh = inspect(&disks, bucket, object, *transaction).await;
|
||||
if fresh.iter().any(|state| !matches!(state, Ok(state) if matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))) {
|
||||
summary.pending += 1;
|
||||
return Ok(summary);
|
||||
@@ -1295,7 +1086,7 @@ impl SetDisks {
|
||||
}
|
||||
for (index, mut copies) in settled.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
copies = inspect(&disks, bucket, object, copies[0].id.transaction, work_budget.as_deref())
|
||||
copies = inspect(&disks, bucket, object, copies[0].id.transaction)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
@@ -1315,10 +1106,7 @@ impl SetDisks {
|
||||
&mut copies,
|
||||
|copy| (copy.state == DeleteRecoveryState::CommitFinished).then_some(DeleteRecoveryAction::FinalizeCommit),
|
||||
can_commit,
|
||||
RecoveryWorkScope {
|
||||
namespace_owner: namespace_owner.as_ref(),
|
||||
budget: work_budget.as_deref(),
|
||||
},
|
||||
namespace_owner.as_ref(),
|
||||
&mut summary.removed_versions,
|
||||
)
|
||||
.await?
|
||||
@@ -1570,459 +1358,6 @@ mod tests {
|
||||
body
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_mixed_inspected_and_deferred_receipts_count_only_real_quorum() {
|
||||
let bucket = "recover-bounded-mixed-quorum";
|
||||
let (_dirs, store, disks) = fixture(bucket).await;
|
||||
put(&store, bucket, &vec![73; 4096], false).await;
|
||||
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
|
||||
let mut outcomes = Vec::new();
|
||||
for (index, disk) in disks.iter().enumerate() {
|
||||
let state = disk
|
||||
.inspect_delete_transaction_bounded(bucket, "object", transaction, 65536)
|
||||
.await
|
||||
.expect("preparation inspection");
|
||||
let max_entries = match index {
|
||||
0 => 65536,
|
||||
1 | 2 => state.visited,
|
||||
_ => 1,
|
||||
};
|
||||
outcomes.push(
|
||||
disk.settle_delete_transaction_bounded(
|
||||
bucket,
|
||||
crate::disk::BoundedDeleteRecoverySettlement {
|
||||
object: "object".to_owned(),
|
||||
transaction,
|
||||
expected_fingerprint: state.inspection.fingerprint,
|
||||
action: DeleteRecoveryAction::PublishCommit,
|
||||
max_entries,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|reply| reply.outcome),
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
matches!(&outcomes[0], Ok(crate::disk::DeleteRecoverySettlementOutcome::Inspected(state)) if receipt(state.state))
|
||||
);
|
||||
assert!(matches!(
|
||||
&outcomes[1],
|
||||
Ok(crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred)
|
||||
));
|
||||
assert!(matches!(outcomes[3], Err(crate::disk::error::DiskError::DeleteRecoveryWorkLimit)));
|
||||
assert!(has_receipt_quorum(&outcomes, 3));
|
||||
assert!(!has_receipt_quorum(&outcomes, 4));
|
||||
outcomes[2] = Err(DiskError::FileAccessDenied);
|
||||
assert!(
|
||||
!has_receipt_quorum(&outcomes, 3),
|
||||
"a write failure cannot substitute for a durable receipt"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_single_and_batch_share_bounded_work_across_versions_and_retries() {
|
||||
use crate::object_api::DeleteRecoveryWorkBudget;
|
||||
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||
let bucket = "recover-bounded-request";
|
||||
let (dirs, store, _) = fixture(bucket).await;
|
||||
for dir in &dirs {
|
||||
let prefix = dir.path().join(bucket).join("prefix");
|
||||
std::fs::create_dir_all(&prefix).expect("physical prefix");
|
||||
for _ in 0..64 {
|
||||
std::fs::create_dir(prefix.join(Uuid::new_v4().to_string())).expect("legitimate child");
|
||||
}
|
||||
}
|
||||
let error = store
|
||||
.delete_object(
|
||||
bucket,
|
||||
"prefix",
|
||||
ObjectOptions {
|
||||
delete_recovery_work_budget: Some(Arc::new(DeleteRecoveryWorkBudget::new(8))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("incomplete discovery is not absence");
|
||||
assert!(matches!(error, StorageError::DeleteRecoveryWorkLimit), "{error:?}");
|
||||
for incarnation in [
|
||||
None,
|
||||
Some(
|
||||
store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("bucket incarnation"),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
!store
|
||||
.ctx
|
||||
.delete_recovery_hints()
|
||||
.is_pending(&crate::store::delete_recovery_runtime::DeleteRecoveryKey {
|
||||
pool: 0,
|
||||
set: 0,
|
||||
bucket: bucket.to_owned(),
|
||||
object: "prefix".to_owned(),
|
||||
incarnation,
|
||||
}),
|
||||
"an unproven prefix must not move its scan into background recovery"
|
||||
);
|
||||
}
|
||||
for distinct_versions in [false, true] {
|
||||
let budget = Arc::new(DeleteRecoveryWorkBudget::new(32_768));
|
||||
let objects = (0..1000)
|
||||
.map(|_| ObjectToDelete {
|
||||
object_name: "prefix".to_owned(),
|
||||
version_id: distinct_versions.then(Uuid::new_v4),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let (_, errors) = store
|
||||
.delete_objects(
|
||||
bucket,
|
||||
objects,
|
||||
ObjectOptions {
|
||||
delete_recovery_work_budget: Some(Arc::clone(&budget)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|error| matches!(error, Some(StorageError::DeleteRecoveryWorkLimit))),
|
||||
"repeated actions must not each obtain a new allowance: {distinct_versions}"
|
||||
);
|
||||
assert!(dirs.iter().all(|dir| {
|
||||
std::fs::read_dir(dir.path().join(bucket).join("prefix"))
|
||||
.map(|entries| entries.count() == 64)
|
||||
.unwrap_or(false)
|
||||
}));
|
||||
}
|
||||
let error = store
|
||||
.delete_object(bucket, "prefix", ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("ordinary missing key");
|
||||
assert!(
|
||||
crate::error::is_err_object_not_found(&error),
|
||||
"small complete inventory retains no-op semantics: {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_batch_budget_survives_pool_and_set_fanout() {
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
let mut dirs = Vec::new();
|
||||
let mut pools = Vec::new();
|
||||
for pool_index in 0..2 {
|
||||
let mut endpoints = Vec::new();
|
||||
for set_index in 0..2 {
|
||||
for disk_index in 0..4 {
|
||||
let dir = tempfile::tempdir().expect("temporary disk");
|
||||
let mut endpoint = Endpoint::try_from(dir.path().to_str().expect("disk path")).expect("endpoint");
|
||||
endpoint.set_pool_index(pool_index);
|
||||
endpoint.set_set_index(set_index);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
endpoints.push(endpoint);
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
pools.push(PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 2,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: format!("bounded-recovery-{pool_index}"),
|
||||
platform: "test".to_owned(),
|
||||
});
|
||||
}
|
||||
let endpoints = EndpointServerPools(pools);
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
crate::store::init_local_disks_with_instance_ctx(&ctx, endpoints.clone())
|
||||
.await
|
||||
.expect("local disks");
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
let store = ECStore::new_with_instance_ctx("127.0.0.1:0".parse().expect("address"), endpoints, cancel.clone(), ctx)
|
||||
.await
|
||||
.expect("two pools with two sets");
|
||||
cancel.cancel();
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "recover-bounded-fanout";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket");
|
||||
let candidates = (0..100).map(|index| format!("prefix-{index}")).collect::<Vec<_>>();
|
||||
let mut keys = BTreeSet::new();
|
||||
for pool in &store.pools {
|
||||
for set in &pool.disk_set {
|
||||
keys.insert(
|
||||
candidates
|
||||
.iter()
|
||||
.find(|key| Arc::ptr_eq(&pool.get_disks_by_key(key), set))
|
||||
.expect("fixture key for set")
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let keys = keys.into_iter().collect::<Vec<_>>();
|
||||
for pool in &store.pools {
|
||||
for set in &pool.disk_set {
|
||||
assert!(
|
||||
keys.iter().any(|key| Arc::ptr_eq(&pool.get_disks_by_key(key), set)),
|
||||
"fixture must cover every set"
|
||||
);
|
||||
}
|
||||
}
|
||||
for dir in &dirs {
|
||||
for key in &keys {
|
||||
let prefix = dir.path().join(bucket).join(key);
|
||||
std::fs::create_dir_all(&prefix).expect("physical prefix");
|
||||
for _ in 0..8 {
|
||||
std::fs::create_dir(prefix.join(Uuid::new_v4().to_string())).expect("child");
|
||||
}
|
||||
}
|
||||
}
|
||||
let budget = Arc::new(crate::object_api::DeleteRecoveryWorkBudget::new(32_768));
|
||||
let objects = (0..1000)
|
||||
.map(|index| crate::storage_api_contracts::object::ObjectToDelete {
|
||||
object_name: keys[index % keys.len()].clone(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let (_, errors) = store
|
||||
.delete_objects(
|
||||
bucket,
|
||||
objects,
|
||||
ObjectOptions {
|
||||
delete_recovery_work_budget: Some(Arc::clone(&budget)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|error| matches!(error, Some(StorageError::DeleteRecoveryWorkLimit))),
|
||||
"all pools and sets must share the finite allowance"
|
||||
);
|
||||
assert!(budget.remaining() < 32_768, "fanout must debit the original request allowance");
|
||||
assert!(dirs.iter().all(|dir| {
|
||||
std::fs::read_dir(dir.path().join(bucket).join(&keys[0]))
|
||||
.map(|entries| entries.count() == 8)
|
||||
.unwrap_or(false)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_fast_commit_keeps_success_when_cleanup_budget_is_empty() {
|
||||
let bucket = "recover-bounded-fast-commit";
|
||||
let (_dirs, store, disks) = fixture(bucket).await;
|
||||
put(&store, bucket, &vec![74; 2 * 1024 * 1024], false).await;
|
||||
// Inline cleanup needs no directory enumeration and may legitimately
|
||||
// finish with zero scan credits. Require real part files here.
|
||||
for disk in &disks {
|
||||
let info = disk
|
||||
.read_version("", bucket, "object", "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("read non-inline source");
|
||||
assert!(!info.inline_data(), "the fixture must require physical part cleanup");
|
||||
let data_dir = info.data_dir.expect("physical data directory");
|
||||
let part = std::path::PathBuf::from(disk.endpoint().to_string())
|
||||
.join(bucket)
|
||||
.join("object")
|
||||
.join(data_dir.to_string())
|
||||
.join("part.1");
|
||||
assert!(part.is_file(), "the cleanup scan must encounter a real part");
|
||||
}
|
||||
store
|
||||
.delete_object(
|
||||
bucket,
|
||||
"object",
|
||||
ObjectOptions {
|
||||
delete_recovery_work_budget: Some(Arc::new(crate::object_api::DeleteRecoveryWorkBudget::new(0))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a cached prepared receipt commits independently of cleanup budget");
|
||||
assert!(
|
||||
store
|
||||
.ctx
|
||||
.delete_recovery_hints()
|
||||
.is_pending(&crate::store::delete_recovery_runtime::DeleteRecoveryKey {
|
||||
pool: 0,
|
||||
set: 0,
|
||||
bucket: bucket.to_owned(),
|
||||
object: "object".to_owned(),
|
||||
incarnation: Some(
|
||||
store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("bucket incarnation")
|
||||
),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!disks[0]
|
||||
.discover_delete_transactions(bucket, Some("object"), None, 128)
|
||||
.await
|
||||
.expect("retained recovery proof")
|
||||
.transactions
|
||||
.is_empty()
|
||||
);
|
||||
store
|
||||
.recover_delete_transactions_at_incarnation(bucket, Some("object"), None, &HealOpts::default())
|
||||
.await
|
||||
.expect("background finishes committed cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_unverified_uuid_candidate_does_not_enqueue_background_work() {
|
||||
let bucket = "recover-bounded-candidate";
|
||||
let (dirs, store, _) = fixture(bucket).await;
|
||||
let transaction = Uuid::new_v4();
|
||||
for directory in &dirs {
|
||||
let candidate = directory.path().join(bucket).join("candidate").join(transaction.to_string());
|
||||
std::fs::create_dir_all(&candidate).expect("candidate path");
|
||||
std::fs::write(candidate.join("delete-prepared"), b"not a bound journal").expect("unverified candidate");
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.delete_object(bucket, "candidate", ObjectOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for incarnation in [
|
||||
None,
|
||||
Some(
|
||||
store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("bucket incarnation"),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
!store
|
||||
.ctx
|
||||
.delete_recovery_hints()
|
||||
.is_pending(&crate::store::delete_recovery_runtime::DeleteRecoveryKey {
|
||||
pool: 0,
|
||||
set: 0,
|
||||
bucket: bucket.to_owned(),
|
||||
object: "candidate".to_owned(),
|
||||
incarnation,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_budget_refusal_preserves_evidence_for_background_replay() {
|
||||
let bucket = "recover-bounded-evidence";
|
||||
let (_dirs, store, disks) = fixture(bucket).await;
|
||||
let payload = vec![71; 4096];
|
||||
put(&store, bucket, &payload, false).await;
|
||||
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
|
||||
let before = disks[0]
|
||||
.inspect_delete_transaction(bucket, "object", transaction)
|
||||
.await
|
||||
.expect("before refusal");
|
||||
let error = store
|
||||
.delete_object(
|
||||
bucket,
|
||||
"object",
|
||||
ObjectOptions {
|
||||
delete_recovery_work_budget: Some(Arc::new(crate::object_api::DeleteRecoveryWorkBudget::new(1))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("no incomplete recovery admission");
|
||||
assert!(matches!(error, StorageError::DeleteRecoveryWorkLimit), "{error:?}");
|
||||
let after = disks[0]
|
||||
.inspect_delete_transaction(bucket, "object", transaction)
|
||||
.await
|
||||
.expect("after refusal");
|
||||
assert_eq!(before.fingerprint, after.fingerprint);
|
||||
store
|
||||
.recover_delete_transactions_at_incarnation(bucket, Some("object"), None, &HealOpts::default())
|
||||
.await
|
||||
.expect("background-compatible recovery");
|
||||
assert_eq!(body(&store, bucket, None).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_committed_delete_defers_exhausted_cleanup_without_rollback() {
|
||||
let bucket = "recover-bounded-commit";
|
||||
let (_dirs, store, disks) = fixture(bucket).await;
|
||||
put(&store, bucket, &vec![72; 4096], false).await;
|
||||
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
let key = crate::store::delete_recovery_runtime::DeleteRecoveryKey {
|
||||
pool: 0,
|
||||
set: 0,
|
||||
bucket: bucket.to_owned(),
|
||||
object: "object".to_owned(),
|
||||
incarnation: None,
|
||||
};
|
||||
let ticket = set.ctx.delete_recovery_hints().register(key.clone());
|
||||
let budget = Arc::new(crate::object_api::DeleteRecoveryWorkBudget::default());
|
||||
let exhausted_after_commit = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
// This hook disables local fast cleanup and runs only after receipt
|
||||
// publication and the shared recovery path's key-wide GC inspection.
|
||||
// Exhausting preparation instead would correctly reject the delete.
|
||||
test_hooks::before_gc_finalize(bucket, "object", {
|
||||
let budget = Arc::clone(&budget);
|
||||
let exhausted_after_commit = Arc::clone(&exhausted_after_commit);
|
||||
Arc::new(move || {
|
||||
let budget = Arc::clone(&budget);
|
||||
let exhausted_after_commit = Arc::clone(&exhausted_after_commit);
|
||||
Box::pin(async move {
|
||||
assert!(budget.reserve(u32::MAX).expect("credits remain until committed cleanup") > 0);
|
||||
exhausted_after_commit.store(true, std::sync::atomic::Ordering::Release);
|
||||
})
|
||||
})
|
||||
});
|
||||
set.publish_delete_commit(
|
||||
bucket,
|
||||
"object",
|
||||
transaction,
|
||||
&set.disk_inventory().await,
|
||||
set.set_drive_count / 2 + 1,
|
||||
DeleteRecoveryContext {
|
||||
work_budget: Some(Arc::clone(&budget)),
|
||||
recovery_ticket: ticket,
|
||||
..DeleteRecoveryContext::new(None, &|| true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the durable decision still succeeds");
|
||||
assert!(exhausted_after_commit.load(std::sync::atomic::Ordering::Acquire));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
assert!(set.ctx.delete_recovery_hints().is_pending(&key));
|
||||
let state = disks[0]
|
||||
.inspect_delete_transaction(bucket, "object", transaction)
|
||||
.await
|
||||
.expect("retained decision");
|
||||
assert!(receipt(state.state));
|
||||
store
|
||||
.recover_delete_transactions_at_incarnation(bucket, Some("object"), None, &HealOpts::default())
|
||||
.await
|
||||
.expect("eventual cleanup");
|
||||
assert!(
|
||||
disks[0]
|
||||
.discover_delete_transactions(bucket, Some("object"), None, 128)
|
||||
.await
|
||||
.expect("cleaned proofs")
|
||||
.transactions
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_set_rolls_back_undecided_inline_bytes() {
|
||||
let bucket = "recover-undecided";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3914,7 +3914,6 @@ impl SetDisks {
|
||||
bucket,
|
||||
object,
|
||||
DeleteRecoveryContext {
|
||||
work_budget: opts.delete_recovery_work_budget.clone(),
|
||||
explicit_delete: true,
|
||||
transaction_guard: transaction_guard.cloned(),
|
||||
namespace_owner: Some(Arc::new((
|
||||
@@ -4799,27 +4798,6 @@ impl SetDisks {
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Lock order: lifecycle, metadata transaction, then object namespace.
|
||||
// No body reads occur below this point. The snapshot must describe the
|
||||
// configuration at commit, not the pre-body advisory object lookup.
|
||||
let mut commit_opts;
|
||||
let opts = if opts.defer_put_object_lock_snapshot {
|
||||
commit_opts = opts.clone();
|
||||
let expected = source_bucket_incarnation_id
|
||||
.ok_or_else(|| Error::other("destructive PUT is missing its bucket incarnation"))?;
|
||||
let store = metadata_sys::object_store_in(&self.ctx).await?;
|
||||
let snapshot = store.object_lock_config_snapshot_for_single_put(bucket).await?;
|
||||
if !snapshot.is_valid_for_destructive_put(store.id, bucket, expected) {
|
||||
return Err(Error::other("PUT bucket generation or configuration changed while staging"));
|
||||
}
|
||||
snapshot.add_lock_fences(&mut commit_opts);
|
||||
commit_opts.object_lock_config_snapshot = Some(snapshot);
|
||||
commit_opts.defer_put_object_lock_snapshot = false;
|
||||
&commit_opts
|
||||
} else {
|
||||
opts
|
||||
};
|
||||
|
||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||
#[cfg(test)]
|
||||
{
|
||||
@@ -5313,7 +5291,6 @@ impl SetDisks {
|
||||
let commit_decommission_object_lock_guard = decommission_object_lock_guard.take();
|
||||
let commit_publication_guard = publication_commit_guard.take();
|
||||
let commit_bucket_lifecycle_guard = bucket_lifecycle_guard.take();
|
||||
let commit_object_lock_config_snapshot = opts.object_lock_config_snapshot.clone();
|
||||
let commit_decommission_capacity_guard = decommission_capacity_guard.take();
|
||||
let commit_scanner_publication_scope = opts.scanner_publication_commit_scope.clone();
|
||||
// A scanner publication scope owns the movement permit until the
|
||||
@@ -5376,7 +5353,6 @@ impl SetDisks {
|
||||
let mut _decommission_object_lock_guard = commit_decommission_object_lock_guard;
|
||||
let mut _publication_guard = commit_publication_guard;
|
||||
let mut _bucket_lifecycle_guard = commit_bucket_lifecycle_guard;
|
||||
let mut _object_lock_config_snapshot = commit_object_lock_config_snapshot;
|
||||
let mut _decommission_capacity_guard = commit_decommission_capacity_guard;
|
||||
let mut quota_reservation = quota_reservation;
|
||||
let rename_stage_start = Instant::now();
|
||||
@@ -5710,7 +5686,6 @@ impl SetDisks {
|
||||
let object_lock_guard = _object_lock_guard.take();
|
||||
let publication_guard = _publication_guard.take();
|
||||
let bucket_lifecycle_guard = _bucket_lifecycle_guard.take();
|
||||
let object_lock_config_snapshot = _object_lock_config_snapshot.take();
|
||||
let decommission_object_lock_guard = _decommission_object_lock_guard.take();
|
||||
let decommission_capacity_guard = _decommission_capacity_guard.take();
|
||||
let cleanup_bucket = commit_bucket.clone();
|
||||
@@ -5733,7 +5708,6 @@ impl SetDisks {
|
||||
write_context,
|
||||
publication_guard,
|
||||
bucket_lifecycle_guard,
|
||||
object_lock_config_snapshot,
|
||||
decommission_object_lock_guard,
|
||||
decommission_capacity_guard,
|
||||
),
|
||||
@@ -5755,7 +5729,6 @@ impl SetDisks {
|
||||
write_context,
|
||||
publication_guard,
|
||||
bucket_lifecycle_guard,
|
||||
object_lock_config_snapshot,
|
||||
decommission_object_lock_guard,
|
||||
decommission_capacity_guard,
|
||||
),
|
||||
@@ -5769,7 +5742,6 @@ impl SetDisks {
|
||||
drop(write_context);
|
||||
drop(publication_guard);
|
||||
drop(bucket_lifecycle_guard);
|
||||
drop(object_lock_config_snapshot);
|
||||
cleanup_runtime::run(cleanup_reservation, async move {
|
||||
let zero_target_cleanup = targets.is_empty();
|
||||
rustfs_io_metrics::record_put_object_stage_duration(
|
||||
@@ -9184,9 +9156,6 @@ impl SetDisks {
|
||||
&disks,
|
||||
write_quorum,
|
||||
DeleteRecoveryContext {
|
||||
work_budget: commit_context
|
||||
.options
|
||||
.and_then(|options| options.delete_recovery_work_budget.clone()),
|
||||
recovery_ticket,
|
||||
transaction_guard: commit_context.transaction_guard.clone(),
|
||||
namespace_owner: namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>),
|
||||
@@ -9750,26 +9719,9 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
if let Some(etag) = &src_info.etag {
|
||||
replacement_metadata.insert("etag".to_owned(), etag.clone());
|
||||
}
|
||||
// ObjectInfo externalizes these fields; copy callers supply the effective
|
||||
// values, including empty tags/absent expiry for explicit replacement.
|
||||
if !src_info.user_tags.is_empty() {
|
||||
replacement_metadata
|
||||
.entry(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING.to_string())
|
||||
.or_insert_with(|| (*src_info.user_tags).clone());
|
||||
}
|
||||
if let Some(expires) = src_info.expires
|
||||
&& !replacement_metadata.contains_key("expires")
|
||||
{
|
||||
replacement_metadata.insert(
|
||||
"expires".to_string(),
|
||||
expires
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(Error::other)?,
|
||||
);
|
||||
}
|
||||
fi.metadata = replacement_metadata.clone();
|
||||
|
||||
let mod_time = dst_opts.mod_time.unwrap_or_else(OffsetDateTime::now_utc);
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
fi.mod_time = Some(mod_time);
|
||||
fi.version_id = version_id;
|
||||
fi.versioned = src_opts.versioned || src_opts.version_suspended;
|
||||
@@ -9892,7 +9844,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
objects: Vec<ObjectToDelete>,
|
||||
mut opts: ObjectOptions,
|
||||
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
|
||||
opts.ensure_delete_recovery_work_budget();
|
||||
let mut del_objects = vec![DeletedObject::default(); objects.len()];
|
||||
let mut accounting = vec![None; objects.len()];
|
||||
let delete_config_snapshot = opts
|
||||
@@ -10164,7 +10115,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
bucket,
|
||||
&dobj.object_name,
|
||||
DeleteRecoveryContext {
|
||||
work_budget: opts.delete_recovery_work_budget.clone(),
|
||||
dry_run: !opts.no_lock,
|
||||
transaction_guard: transaction_guards.get(lock_names[i].as_ref()).cloned(),
|
||||
namespace_owner: opts.delete_mutation_owner.clone(),
|
||||
@@ -10773,7 +10723,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
disks,
|
||||
self.set_drive_count / 2 + 1,
|
||||
DeleteRecoveryContext {
|
||||
work_budget: opts.delete_recovery_work_budget.clone(),
|
||||
recovery_ticket,
|
||||
namespace_owner,
|
||||
transaction_guard,
|
||||
@@ -10957,7 +10906,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
bucket,
|
||||
&object.object_name,
|
||||
DeleteRecoveryContext {
|
||||
work_budget: opts.delete_recovery_work_budget.clone(),
|
||||
explicit_delete,
|
||||
namespace_owner: Some(Arc::new((
|
||||
self.ctx.begin_namespace_commit(),
|
||||
@@ -11016,7 +10964,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
|
||||
#[tracing::instrument(skip(self, opts))]
|
||||
async fn delete_object(&self, bucket: &str, object: &str, mut opts: ObjectOptions) -> Result<ObjectInfo> {
|
||||
opts.ensure_delete_recovery_work_budget();
|
||||
let _scope_outcome_guard = opts
|
||||
.scanner_publication_commit_scope
|
||||
.clone()
|
||||
@@ -11313,7 +11260,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
bucket,
|
||||
object,
|
||||
DeleteRecoveryContext {
|
||||
work_budget: opts.delete_recovery_work_budget.clone(),
|
||||
transaction_guard: transaction_guard.clone(),
|
||||
namespace_owner: Some(Arc::new((
|
||||
self.ctx.begin_namespace_commit(),
|
||||
@@ -11925,10 +11871,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
// as a second object header set: forwarding SSE intent or wrapped DEKs
|
||||
// would request a second encryption pass and disclose local envelope
|
||||
// material to the remote provider.
|
||||
transition_meta.retain(|key, _| {
|
||||
!rustfs_utils::http::is_replication_stripped_encryption_key(key)
|
||||
&& !rustfs_utils::http::is_snowball_transport_key(key)
|
||||
});
|
||||
transition_meta.retain(|key, _| !rustfs_utils::http::is_replication_stripped_encryption_key(key));
|
||||
transition_meta.insert("name".to_string(), object.to_string());
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut transition_meta,
|
||||
@@ -16404,170 +16347,6 @@ mod metadata_mutation_generation_tests {
|
||||
assert_retired(&set_disks, &metadata_key).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_metadata_copy_preserves_tags_expiry_and_version_order() {
|
||||
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
||||
use s3s::dto::RestoreRequest;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
let (_temp_dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "restore-metadata-preservation";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("create bucket");
|
||||
}
|
||||
let old_time = OffsetDateTime::UNIX_EPOCH + time::Duration::days(1);
|
||||
let latest_time = old_time + time::Duration::days(1);
|
||||
let expires = latest_time + time::Duration::days(365);
|
||||
for deleted in [false, true] {
|
||||
let object = if deleted { "deleted" } else { "live" };
|
||||
let tags = "classification=secret&owner=alice";
|
||||
let old = set
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"old protected content".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
mod_time: Some(old_time),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
user_defined: HashMap::from([
|
||||
(AMZ_OBJECT_TAGGING.to_string(), tags.to_string()),
|
||||
("x-amz-tagging".to_string(), tags.to_string()),
|
||||
("expires".to_string(), expires.format(&Rfc3339).expect("expiry")),
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write historical version");
|
||||
let latest = if deleted {
|
||||
let marker = FileInfo {
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
mod_time: Some(latest_time),
|
||||
deleted: true,
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
};
|
||||
for disk in &disks {
|
||||
disk.write_metadata(bucket, bucket, object, marker.clone())
|
||||
.await
|
||||
.expect("publish current delete marker");
|
||||
}
|
||||
marker.version_id
|
||||
} else {
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"latest content".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
mod_time: Some(latest_time),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write latest version")
|
||||
.version_id
|
||||
};
|
||||
let opts = ObjectOptions {
|
||||
version_id: old.version_id.map(|id| id.to_string()),
|
||||
versioned: true,
|
||||
mod_time: Some(old_time),
|
||||
..Default::default()
|
||||
};
|
||||
let mut source = set.get_object_info(bucket, object, &opts).await.expect("read old metadata");
|
||||
assert_eq!(source.user_tags.as_str(), tags);
|
||||
assert_eq!(source.expires, Some(expires));
|
||||
assert!(!source.user_defined.contains_key(AMZ_OBJECT_TAGGING));
|
||||
assert!(source.user_defined.contains_key("x-amz-tagging"));
|
||||
source.metadata_only = true;
|
||||
Arc::make_mut(&mut source.user_defined)
|
||||
.insert(metadata_keys::RESTORE.to_string(), "ongoing-request=\"true\"".to_string());
|
||||
set.copy_object(bucket, object, bucket, object, &mut source, &opts, &opts)
|
||||
.await
|
||||
.expect("restore metadata update");
|
||||
let restored = set
|
||||
.get_object_info(bucket, object, &opts)
|
||||
.await
|
||||
.expect("read restored metadata");
|
||||
assert_eq!(restored.user_tags.as_str(), tags);
|
||||
assert_eq!(restored.expires, Some(expires));
|
||||
assert_eq!(restored.mod_time, Some(old_time));
|
||||
for disk in &disks {
|
||||
let bytes = disk
|
||||
.read_all(bucket, &format!("{object}/xl.meta"))
|
||||
.await
|
||||
.expect("persisted metadata");
|
||||
let meta = FileMeta::load(&bytes).expect("decode versions");
|
||||
let versions = meta.list_versions(bucket, object, false).expect("list versions");
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert_eq!(versions[0].version_id, latest);
|
||||
assert_eq!(versions[0].mod_time, Some(latest_time));
|
||||
assert_eq!(versions[0].deleted, deleted);
|
||||
assert_eq!(versions[1].version_id, old.version_id);
|
||||
assert_eq!(versions[1].mod_time, Some(old_time));
|
||||
}
|
||||
let restore_opts = put_restore_opts(bucket, object, &RestoreRequest::default(), &restored)
|
||||
.await
|
||||
.expect("background restore options");
|
||||
assert_eq!(restore_opts.user_defined.get(AMZ_OBJECT_TAGGING).map(String::as_str), Some(tags));
|
||||
assert_eq!(restore_opts.user_defined.get("expires"), Some(&expires.format(&Rfc3339).expect("expiry")));
|
||||
assert_eq!(restore_opts.mod_time, Some(old_time));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_copy_honors_explicit_tag_and_expiry_replacement() {
|
||||
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
let (_temp_dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "copy-metadata-replacement";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("create bucket");
|
||||
}
|
||||
let old_time = OffsetDateTime::UNIX_EPOCH + time::Duration::days(1);
|
||||
let expires = old_time + time::Duration::days(365);
|
||||
set.put_object(
|
||||
bucket,
|
||||
"object",
|
||||
&mut PutObjReader::from_vec(b"content".to_vec()),
|
||||
&ObjectOptions {
|
||||
mod_time: Some(old_time),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write original");
|
||||
let opts = ObjectOptions::default();
|
||||
let mut source = set.get_object_info(bucket, "object", &opts).await.expect("original metadata");
|
||||
source.metadata_only = true;
|
||||
source.user_tags = Arc::new("old=value".to_string());
|
||||
source.expires = Some(expires - time::Duration::days(1));
|
||||
let metadata = Arc::make_mut(&mut source.user_defined);
|
||||
metadata.insert(AMZ_OBJECT_TAGGING.to_string(), "new=value".to_string());
|
||||
metadata.insert("expires".to_string(), expires.format(&Rfc3339).expect("expiry"));
|
||||
set.copy_object(bucket, "object", bucket, "object", &mut source, &opts, &opts)
|
||||
.await
|
||||
.expect("explicit metadata wins over externalized fields");
|
||||
let mut copied = set.get_object_info(bucket, "object", &opts).await.expect("copied metadata");
|
||||
assert_eq!(copied.user_tags.as_str(), "new=value");
|
||||
assert_eq!(copied.expires, Some(expires));
|
||||
assert!(copied.mod_time.is_some_and(|time| time > old_time));
|
||||
|
||||
copied.metadata_only = true;
|
||||
copied.user_tags = Arc::new(String::new());
|
||||
copied.expires = None;
|
||||
set.copy_object(bucket, "object", bucket, "object", &mut copied, &opts, &opts)
|
||||
.await
|
||||
.expect("explicit empty replacement");
|
||||
let cleared = set.get_object_info(bucket, "object", &opts).await.expect("cleared metadata");
|
||||
assert!(cleared.user_tags.is_empty());
|
||||
assert!(cleared.expires.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_only_copy_preserves_valid_part_checksums_and_rejects_conflicting_aliases() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
@@ -17020,19 +16799,6 @@ mod transition_commit_failure_tests {
|
||||
.collect();
|
||||
user_defined.insert("x-amz-meta-owner".to_string(), "finance".to_string());
|
||||
|
||||
let snowball_keys = [
|
||||
"snowball-auto-extract",
|
||||
"x-amz-meta-snowball-auto-extract",
|
||||
"x-amz-snowball-auto-extract",
|
||||
"snowball-prefix",
|
||||
"x-amz-meta-rustfs-snowball-prefix",
|
||||
"x-amz-meta-minio-snowball-ignore-dirs",
|
||||
"other-vendor-snowball-ignore-errors",
|
||||
];
|
||||
for key in snowball_keys {
|
||||
user_defined.insert(key.to_string(), "true".to_string());
|
||||
}
|
||||
|
||||
let mut reader = PutObjReader::from_vec(b"stored bytes the tier keeps opaque ".repeat(64));
|
||||
set_disks
|
||||
.put_object(
|
||||
@@ -17082,10 +16848,6 @@ mod transition_commit_failure_tests {
|
||||
.await
|
||||
.expect("the tier must have received the object");
|
||||
|
||||
for key in snowball_keys {
|
||||
assert!(!remote_metadata.contains_key(key), "tier must receive opaque bytes, not {key}");
|
||||
assert_eq!(transitioned.user_defined.get(key).map(String::as_str), Some("true"));
|
||||
}
|
||||
for (key, _) in encryption_metadata {
|
||||
assert!(
|
||||
!remote_metadata.keys().any(|stored| stored.eq_ignore_ascii_case(key)),
|
||||
|
||||
@@ -52,8 +52,8 @@ pub(crate) mod list {
|
||||
|
||||
pub(crate) mod multipart {
|
||||
pub(crate) use rustfs_storage_api::{
|
||||
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartCompletionAuthorization, MultipartInfo, MultipartOperations,
|
||||
MultipartUploadInspection, MultipartUploadResult, PartInfo,
|
||||
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartOperations, MultipartUploadInspection,
|
||||
MultipartUploadResult, PartInfo,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::bucket::{
|
||||
metadata::{BUCKET_TABLE_RESERVED_PREFIX, BUCKET_TAGGING_CONFIG, ConfigState},
|
||||
metadata::{BUCKET_TABLE_RESERVED_PREFIX, BUCKET_TAGGING_CONFIG, ConfigState, table_bucket_catalog_metadata_prefix},
|
||||
utils::is_meta_bucketname,
|
||||
};
|
||||
use crate::error::is_err_bucket_not_found;
|
||||
@@ -145,41 +145,11 @@ async fn table_bucket_enabled_in(ctx: &crate::runtime::instance::InstanceContext
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket_delete_metadata_cleanup_prefixes(bucket: &str) -> Vec<String> {
|
||||
use crate::bucket::metadata::*;
|
||||
|
||||
let mut prefixes = vec![table_bucket_catalog_metadata_prefix(bucket)];
|
||||
let root = format!("{BUCKET_META_PREFIX}/{bucket}");
|
||||
if bucket != crate::disk::HEAL_REPLACEMENT_RECOVERY_DIR {
|
||||
prefixes.push(root);
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
// This valid user bucket shares its metadata directory with raw drive
|
||||
// replacement intents, seals and recovery proofs. Delete only bucket-owned
|
||||
// paths, including legacy standalone configuration, never the shared root.
|
||||
// S3 objects live in the separate user volume; configuration APIs write the
|
||||
// aggregate metadata file, so clients cannot populate arbitrary siblings.
|
||||
prefixes.extend(
|
||||
[
|
||||
BUCKET_METADATA_FILE,
|
||||
BUCKET_INCARNATION_FILE,
|
||||
BUCKET_POLICY_CONFIG,
|
||||
BUCKET_QUOTA_CONFIG_FILE,
|
||||
BUCKET_TARGETS_FILE,
|
||||
BUCKET_ACL_CONFIG,
|
||||
BUCKET_TABLE_CONFIG,
|
||||
BUCKET_DURABILITY_CONFIG,
|
||||
BUCKET_ON_DEMAND_MIGRATION_CONFIG,
|
||||
".replication",
|
||||
"integrity-jobs",
|
||||
crate::data_usage::DATA_USAGE_CACHE_NAME,
|
||||
]
|
||||
.into_iter()
|
||||
.chain(XML_BUCKET_CONFIG_FILES)
|
||||
.map(|file| format!("{root}/{file}")),
|
||||
);
|
||||
prefixes
|
||||
fn bucket_delete_metadata_cleanup_prefixes(bucket: &str) -> [String; 2] {
|
||||
[
|
||||
table_bucket_catalog_metadata_prefix(bucket),
|
||||
format!("{BUCKET_META_PREFIX}/{bucket}"),
|
||||
]
|
||||
}
|
||||
|
||||
fn bucket_deleted_marker_prefix(bucket: &str) -> String {
|
||||
@@ -190,48 +160,41 @@ fn bucket_deleted_marker_volume(bucket: &str) -> String {
|
||||
format!("{RUSTFS_META_BUCKET}/{}", bucket_deleted_marker_prefix(bucket))
|
||||
}
|
||||
|
||||
// Box the child before constructing either lease wrapper. Keeping F inline in
|
||||
// nested select futures multiplies poll-frame temporaries along DELETE -> quota
|
||||
// cleanup -> metadata GET. This boundary must not inline back into that chain.
|
||||
#[inline(never)]
|
||||
pub(crate) fn await_bucket_namespace_operation<'a, T: 'a, F>(
|
||||
guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
|
||||
bucket: &'a str,
|
||||
pub(crate) async fn await_bucket_namespace_operation<T, F>(
|
||||
guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
bucket: &str,
|
||||
operation: &'static str,
|
||||
future: F,
|
||||
) -> impl Future<Output = Result<T>> + 'a
|
||||
) -> Result<T>
|
||||
where
|
||||
F: Future<Output = Result<T>> + 'a,
|
||||
F: Future<Output = Result<T>>,
|
||||
{
|
||||
let future = Box::pin(future);
|
||||
async move {
|
||||
let Some(guard) = guard else {
|
||||
return future.await;
|
||||
};
|
||||
if guard.is_lock_lost() {
|
||||
return Err(StorageError::other(format!(
|
||||
"bucket namespace lock was lost before {operation}: {bucket}"
|
||||
)));
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = guard.lock_lost_notified() => Err(StorageError::other(format!(
|
||||
"bucket namespace lock was lost during {operation}: {bucket}"
|
||||
))),
|
||||
result = future => result,
|
||||
}
|
||||
let Some(guard) = guard else {
|
||||
return future.await;
|
||||
};
|
||||
if guard.is_lock_lost() {
|
||||
return Err(StorageError::other(format!(
|
||||
"bucket namespace lock was lost before {operation}: {bucket}"
|
||||
)));
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = guard.lock_lost_notified() => Err(StorageError::other(format!(
|
||||
"bucket namespace lock was lost during {operation}: {bucket}"
|
||||
))),
|
||||
result = future => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn await_bucket_lifecycle_operation<'a, T: 'a, F>(
|
||||
lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
|
||||
namespace_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
|
||||
bucket: &'a str,
|
||||
async fn await_bucket_lifecycle_operation<T, F>(
|
||||
lifecycle_guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
namespace_guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
bucket: &str,
|
||||
operation: &'static str,
|
||||
future: F,
|
||||
) -> impl Future<Output = Result<T>> + 'a
|
||||
) -> Result<T>
|
||||
where
|
||||
F: Future<Output = Result<T>> + 'a,
|
||||
F: Future<Output = Result<T>>,
|
||||
{
|
||||
await_bucket_namespace_operation(
|
||||
lifecycle_guard,
|
||||
@@ -239,6 +202,7 @@ where
|
||||
operation,
|
||||
await_bucket_namespace_operation(namespace_guard, bucket, operation, future),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn bucket_recovery_lock_unavailable(bucket: &str, object: &str) -> StorageError {
|
||||
@@ -514,18 +478,10 @@ impl ECStore {
|
||||
let mut tags = HashMap::with_capacity(tagging.tag_set.len());
|
||||
for tag in &tagging.tag_set {
|
||||
let (Some(key), Some(value)) = (&tag.key, &tag.value) else {
|
||||
return Err(crate::bucket::metadata::unreadable_config_error(
|
||||
bucket,
|
||||
BUCKET_TAGGING_CONFIG,
|
||||
metadata.tagging_config_xml.len(),
|
||||
));
|
||||
return Err(Error::other("stored bucket tag is missing a key or value"));
|
||||
};
|
||||
if key.is_empty() || tags.insert(key.clone(), value.clone()).is_some() {
|
||||
return Err(crate::bucket::metadata::unreadable_config_error(
|
||||
bucket,
|
||||
BUCKET_TAGGING_CONFIG,
|
||||
metadata.tagging_config_xml.len(),
|
||||
));
|
||||
return Err(Error::other("stored bucket tags contain empty or duplicate keys"));
|
||||
}
|
||||
}
|
||||
Ok(tags)
|
||||
@@ -1717,17 +1673,7 @@ impl ECStore {
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket retirement publication",
|
||||
crate::bucket::retirement::commit_retirement(store.clone(), bucket, incarnation, &record_opts),
|
||||
)
|
||||
.await?;
|
||||
// The lifecycle fence excludes live reservation owners. A crash-left
|
||||
// V1 ledger must not block quota admission after same-name recreation.
|
||||
await_bucket_lifecycle_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"retired bucket quota ledger cleanup",
|
||||
crate::bucket::quota::reservation::cleanup_retired_bucket_ledger(store, bucket, incarnation),
|
||||
crate::bucket::retirement::commit_retirement(store, bucket, incarnation, &record_opts),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -1878,20 +1824,6 @@ mod tests {
|
||||
assert!(!xlmeta.diagnostic_truncated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bucket_namespace_operation_keeps_nested_future_state_off_the_poll_stack() {
|
||||
let payload = std::hint::black_box([7u8; 128 * 1024]);
|
||||
let operation = super::await_bucket_lifecycle_operation(None, None, "bucket", "large metadata operation", async move {
|
||||
tokio::task::yield_now().await;
|
||||
Ok(std::hint::black_box(payload)[0])
|
||||
});
|
||||
assert!(
|
||||
std::mem::size_of_val(&operation) < 1024,
|
||||
"nested lease wrappers must not retain the concrete storage future inline"
|
||||
);
|
||||
assert_eq!(operation.await.expect("the boxed operation must still execute"), 7);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn bucket_namespace_operation_fails_closed_after_lease_expiry() {
|
||||
let ttl = Duration::from_millis(20);
|
||||
@@ -2308,13 +2240,7 @@ mod tests {
|
||||
let result = store.get_bucket_tags_for_policy(bucket).await;
|
||||
match expected {
|
||||
Some(tags) => assert_eq!(&result.expect("valid tag configuration"), tags),
|
||||
None => assert!(
|
||||
crate::bucket::metadata::unreadable_config_refusal(
|
||||
&result.expect_err("corrupt tags must not become an untagged bucket")
|
||||
)
|
||||
.is_some_and(|failure| failure.config_file == super::BUCKET_TAGGING_CONFIG),
|
||||
"corrupt tags must carry a typed tagging refusal: {xml:?}"
|
||||
),
|
||||
None => assert!(result.is_err(), "corrupt tags must not become an untagged bucket: {xml:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2516,187 +2442,6 @@ mod tests {
|
||||
assert!(prefixes.contains(&"buckets/analytics".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn replacement_named_bucket_delete_preserves_recovery_and_recreates_clean_metadata() {
|
||||
use crate::bucket::metadata::{BUCKET_QUOTA_CONFIG_FILE, BUCKET_TAGGING_CONFIG, BUCKET_VERSIONING_CONFIG};
|
||||
|
||||
let (_temp, store) = setup_bucket_quorum_test_env(&[4], None).await;
|
||||
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "ahm-replacement";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create legal bucket");
|
||||
let old_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("old incarnation");
|
||||
for (config, bytes) in [
|
||||
(BUCKET_QUOTA_CONFIG_FILE, br#"{"quota":1024,"quota_type":"Hard"}"#.as_slice()),
|
||||
(
|
||||
BUCKET_TAGGING_CONFIG,
|
||||
b"<Tagging><TagSet><Tag><Key>old</Key><Value>value</Value></Tag></TagSet></Tagging>".as_slice(),
|
||||
),
|
||||
(
|
||||
BUCKET_VERSIONING_CONFIG,
|
||||
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".as_slice(),
|
||||
),
|
||||
] {
|
||||
metadata_sys::update_in(&store.ctx, bucket, config, bytes.to_vec())
|
||||
.await
|
||||
.expect("persist old configuration");
|
||||
}
|
||||
let ledger_path = format!("config/quota-ledger/{bucket}.json");
|
||||
// V1 precharges outstanding growth: four live bytes plus the
|
||||
// crash-left reservation's 100 bytes must already be accounted.
|
||||
let ledger = serde_json::to_vec(&serde_json::json!({
|
||||
"version": 1, "bucket_incarnation": old_incarnation,
|
||||
"quota_revision_unix_nanos": 0, "accounted_usage": 104,
|
||||
"reservations": {Uuid::new_v4().to_string(): {
|
||||
"object": "crashed-put", "old_size": 0, "new_size": 100,
|
||||
"created_at": 0, "pool_index": 0, "set_index": 0, "commit_started": false
|
||||
}}, "reconcile_required": true
|
||||
}))
|
||||
.expect("encode crash-left ledger");
|
||||
|
||||
// The storage API requires the same legacy-quota admission supplied
|
||||
// by the S3 handler, even for this small fixture object.
|
||||
let mut live_options = ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(live_options.set_quota_admission(0, 1024));
|
||||
// Failed deletion must leave both the live incarnation and its ledger alone.
|
||||
store
|
||||
.put_object(bucket, "live", &mut PutObjReader::from_vec(b"live".to_vec()), &live_options)
|
||||
.await
|
||||
.expect("write live object");
|
||||
crate::config::com::save_config(store.clone(), &ledger_path, ledger.clone())
|
||||
.await
|
||||
.expect("persist old ledger");
|
||||
assert!(matches!(
|
||||
store.delete_bucket(bucket, &DeleteBucketOptions::default()).await,
|
||||
Err(StorageError::BucketNotEmpty(_))
|
||||
));
|
||||
assert_eq!(
|
||||
crate::config::com::read_config(store.clone(), &ledger_path)
|
||||
.await
|
||||
.expect("live ledger remains"),
|
||||
ledger
|
||||
);
|
||||
// Remove every version; the configured bucket is otherwise still empty.
|
||||
let versions = store
|
||||
.clone()
|
||||
.list_object_versions(bucket, "", None, None, None, 100)
|
||||
.await
|
||||
.expect("list live versions");
|
||||
for object in versions.objects {
|
||||
store
|
||||
.delete_object(
|
||||
bucket,
|
||||
&object.name,
|
||||
ObjectOptions {
|
||||
version_id: object.version_id.map(|id| id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("remove live version");
|
||||
}
|
||||
|
||||
let root = format!("{BUCKET_META_PREFIX}/{bucket}");
|
||||
let task = Uuid::new_v4();
|
||||
let recovery_names = [
|
||||
"ahm_replacement_intent.json",
|
||||
"ahm_replacement_intent_seal",
|
||||
"ahm_replacement_completion_proof.json",
|
||||
]
|
||||
.map(|suffix| format!("{task}_{suffix}"));
|
||||
let disk_paths = store.pools[0].disk_set[0]
|
||||
.disks
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|disk| PathBuf::from(disk.endpoint().to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
for disk in &disk_paths {
|
||||
let directory = disk.join(RUSTFS_META_BUCKET).join(&root);
|
||||
for name in &recovery_names {
|
||||
tokio::fs::write(directory.join(name), b"durable heal record")
|
||||
.await
|
||||
.expect("seed raw recovery record");
|
||||
}
|
||||
// Legacy standalone settings and bucket-owned subtrees must not survive.
|
||||
for name in [
|
||||
"quota.json",
|
||||
"versioning.xml",
|
||||
"policy.json",
|
||||
"bucket-targets.json",
|
||||
"integrity-jobs/job.json",
|
||||
".replication/resync.bin",
|
||||
".usage-cache.bin",
|
||||
] {
|
||||
let path = directory.join(name);
|
||||
tokio::fs::create_dir_all(path.parent().expect("fixture parent"))
|
||||
.await
|
||||
.expect("legacy parent");
|
||||
tokio::fs::write(path, b"old bucket configuration")
|
||||
.await
|
||||
.expect("seed legacy configuration");
|
||||
}
|
||||
}
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("delete legal same-name bucket");
|
||||
assert!(matches!(
|
||||
crate::config::com::read_config(store.clone(), &ledger_path).await,
|
||||
Err(StorageError::ConfigNotFound)
|
||||
));
|
||||
for disk in &disk_paths {
|
||||
let directory = disk.join(RUSTFS_META_BUCKET).join(&root);
|
||||
for name in &recovery_names {
|
||||
assert_eq!(
|
||||
tokio::fs::read(directory.join(name)).await.expect("recovery record survives"),
|
||||
b"durable heal record"
|
||||
);
|
||||
}
|
||||
for prefix in bucket_delete_metadata_cleanup_prefixes(bucket)
|
||||
.into_iter()
|
||||
.filter(|p| p.starts_with(&root))
|
||||
{
|
||||
assert!(
|
||||
!disk.join(RUSTFS_META_BUCKET).join(prefix).exists(),
|
||||
"bucket-owned metadata must be removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate legal bucket");
|
||||
let fresh = metadata_sys::get_in(&store.ctx, bucket).await.expect("fresh metadata");
|
||||
assert_ne!(fresh.bucket_incarnation_id, old_incarnation);
|
||||
assert!(fresh.quota_config_json.is_empty());
|
||||
assert!(fresh.tagging_config_xml.is_empty());
|
||||
assert!(fresh.versioning_config_xml.is_empty());
|
||||
assert!(fresh.policy_config_json.is_empty());
|
||||
// The strict purge/recovery cleanup must share the same ownership boundary.
|
||||
store
|
||||
.cleanup_deleted_bucket_metadata_strict(bucket, true)
|
||||
.await
|
||||
.expect("strict metadata cleanup");
|
||||
for disk in &disk_paths {
|
||||
for name in &recovery_names {
|
||||
assert_eq!(
|
||||
tokio::fs::read(disk.join(RUSTFS_META_BUCKET).join(&root).join(name))
|
||||
.await
|
||||
.expect("strict cleanup preserves recovery"),
|
||||
b"durable heal record"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_delete_marker_path_uses_internal_deleted_bucket_metadata_prefix() {
|
||||
assert_eq!(bucket_deleted_marker_prefix("analytics"), "buckets/.deleted/analytics");
|
||||
|
||||
@@ -368,7 +368,7 @@ impl ECStore {
|
||||
Some(expected) => expected,
|
||||
None => self.bucket_incarnation_id_from_disk(bucket).await?,
|
||||
};
|
||||
let object = object.map(encode_dir_object);
|
||||
let object = object.map(str::to_owned);
|
||||
let prefix = prefix.map(str::to_owned);
|
||||
self.run_bucket_heal_at_incarnation(bucket, expected, opts, |store, bucket, opts| async move {
|
||||
let scope = bucket_heal_scope(&bucket).ok_or(StorageError::PreconditionFailed)?;
|
||||
@@ -430,7 +430,7 @@ impl ECStore {
|
||||
HealItemType::Bucket
|
||||
}
|
||||
.to_string(),
|
||||
object: object.map(|object| decode_dir_object(&object)).or(prefix).unwrap_or_default(),
|
||||
object: object.or(prefix).unwrap_or_default(),
|
||||
bucket,
|
||||
detail: format!(
|
||||
"delete recovery{}: discovered={}, committed={}, rolled_back={}, reclaimed={}, blocked={}, pending={}, failed={}",
|
||||
@@ -656,48 +656,12 @@ impl ECStore {
|
||||
Ok(Arc::ptr_eq(&target_set, &pool.get_disks_by_key(POOL_META_NAME)))
|
||||
}
|
||||
|
||||
/// Read the published bucket identity without fabricating or migrating metadata.
|
||||
/// Absence is distinct from quorum, corruption and transaction-fence failures.
|
||||
pub async fn replacement_bucket_incarnation_from_disk(self: &Arc<Self>, bucket: &str) -> Result<Option<uuid::Uuid>> {
|
||||
let transaction =
|
||||
crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await?;
|
||||
let incarnation = crate::bucket::metadata::load_bucket_incarnation(self.clone(), bucket).await?;
|
||||
if transaction.is_lock_lost() {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
Ok(incarnation)
|
||||
}
|
||||
|
||||
/// Restore the bucket configuration and incarnation before retiring a replacement marker.
|
||||
pub async fn heal_replacement_bucket_metadata(
|
||||
self: &Arc<Self>,
|
||||
bucket: &str,
|
||||
opts: &HealOpts,
|
||||
targets: &[String],
|
||||
) -> Result<()> {
|
||||
// Reject an invalid request before touching the drives so callers keep
|
||||
// the PreconditionFailed contract for bad buckets and options.
|
||||
if opts.pool.is_none()
|
||||
|| opts.set.is_none()
|
||||
|| targets.is_empty()
|
||||
|| opts.dry_run
|
||||
|| opts.no_lock
|
||||
|| check_valid_bucket_name_strict(bucket).is_err()
|
||||
{
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let expected = self.bucket_incarnation_id_from_disk(bucket).await?;
|
||||
self.heal_replacement_bucket_metadata_at_incarnation(bucket, expected, opts, targets)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Keep a replacement pass bound to its admitted bucket through metadata commit.
|
||||
pub async fn heal_replacement_bucket_metadata_at_incarnation(
|
||||
self: &Arc<Self>,
|
||||
bucket: &str,
|
||||
expected: uuid::Uuid,
|
||||
opts: &HealOpts,
|
||||
targets: &[String],
|
||||
) -> Result<()> {
|
||||
use crate::bucket::metadata::{BUCKET_INCARNATION_FILE, BUCKET_METADATA_FILE};
|
||||
use crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in;
|
||||
@@ -734,8 +698,9 @@ impl ECStore {
|
||||
|
||||
// Read the persisted identity without lazy migration: a replacement
|
||||
// must never turn lost configuration into newly fabricated defaults.
|
||||
let incarnation = self.bucket_incarnation_id_from_disk(bucket).await?;
|
||||
let targets = targets.to_vec();
|
||||
self.run_bucket_heal_at_incarnation(bucket, expected, opts, move |store, bucket, opts| async move {
|
||||
self.run_bucket_heal_at_incarnation(bucket, incarnation, opts, move |store, bucket, opts| async move {
|
||||
// Match config writers: lifecycle, metadata transaction, object locks.
|
||||
// Keep the transaction stable through target readback, including when
|
||||
// the caller is cancelled and the storage owner finishes its write.
|
||||
@@ -1057,7 +1022,6 @@ impl ECStore {
|
||||
set_index: usize,
|
||||
targets: &[String],
|
||||
) -> Result<bool> {
|
||||
let object = encode_dir_object(object);
|
||||
let pool = self
|
||||
.pools
|
||||
.get(pool_index)
|
||||
@@ -1070,7 +1034,7 @@ impl ECStore {
|
||||
)
|
||||
})?;
|
||||
|
||||
set.replacement_targets_have_version(bucket, &object, version_id, targets)
|
||||
set.replacement_targets_have_version(bucket, object, version_id, targets)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
@@ -2895,105 +2859,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn replacement_directory_object_readback_and_exact_recovery_use_encoded_key() {
|
||||
let (_root, store, shutdown) = heal_store_with_pool_count(1).await;
|
||||
let bucket = "directory-replacement-readback";
|
||||
let object = "folder/";
|
||||
let encoded = encode_dir_object(object);
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(Vec::new()),
|
||||
&ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write directory object through the public store entry");
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
let targets = vec![set.set_endpoints[0].to_string()];
|
||||
assert!(
|
||||
store
|
||||
.replacement_targets_have_version(bucket, object, "", 0, 0, &targets)
|
||||
.await
|
||||
.expect("readback")
|
||||
);
|
||||
let disk = set.disks.read().await[0].clone().expect("target disk");
|
||||
disk.delete(
|
||||
bucket,
|
||||
&encoded,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
immediate: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("remove target directory shard");
|
||||
assert!(
|
||||
!store
|
||||
.replacement_targets_have_version(bucket, object, "", 0, 0, &targets)
|
||||
.await
|
||||
.expect("missing target")
|
||||
);
|
||||
let opts = HealOpts {
|
||||
pool: Some(0),
|
||||
set: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
let (result, error) = store
|
||||
.heal_object(bucket, object, "", &opts)
|
||||
.await
|
||||
.expect("heal directory object");
|
||||
assert!(error.is_none(), "heal failed: {error:?}");
|
||||
assert_eq!(result.object, object);
|
||||
assert!(
|
||||
store
|
||||
.replacement_targets_have_version(bucket, object, "", 0, 0, &targets)
|
||||
.await
|
||||
.expect("healed readback")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.replacement_targets_have_version(bucket, &encoded, "", 0, 0, &targets)
|
||||
.await
|
||||
.expect("encoded readback")
|
||||
);
|
||||
|
||||
// Exact-object recovery must inspect the same physical namespace and
|
||||
// reject corrupt proof rather than silently report no transactions.
|
||||
let metadata = disk
|
||||
.read_all(bucket, &format!("{encoded}/xl.meta"))
|
||||
.await
|
||||
.expect("healed metadata");
|
||||
disk.write_all(
|
||||
bucket,
|
||||
&format!("{encoded}/{}/delete-prepared", Uuid::new_v4()),
|
||||
b"invalid proof".to_vec().into(),
|
||||
)
|
||||
.await
|
||||
.expect("seed corrupt recovery proof");
|
||||
store
|
||||
.recover_delete_transactions_at_incarnation(bucket, Some(object), None, &HealOpts { dry_run: true, ..opts })
|
||||
.await
|
||||
.expect_err("directory recovery must discover and reject the corrupt proof");
|
||||
assert_eq!(
|
||||
disk.read_all(bucket, &format!("{encoded}/xl.meta"))
|
||||
.await
|
||||
.expect("unchanged metadata"),
|
||||
metadata
|
||||
);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_bucket_metadata_respects_pool_ownership_and_required_records() {
|
||||
let (root, store, shutdown) = multi_pool_heal_store().await;
|
||||
|
||||
+278
-309
@@ -5156,11 +5156,6 @@ mod tests {
|
||||
let offline_disk = force_set_disk_range_offline_for_test(set, 0..1).await;
|
||||
let _without_proof = crate::services::notification_sys::without_cross_pool_fence_fleet_proof_for_test();
|
||||
assert!(crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().is_none());
|
||||
if !sharded {
|
||||
crate::bucket::quota::reservation::initialize_quota_ledger_for_test(Arc::clone(&store), &bucket)
|
||||
.await
|
||||
.expect("bootstrap quota with one disk offline");
|
||||
}
|
||||
|
||||
let payload = vec![0x51; 4096];
|
||||
let mut reader = PutObjReader::from_vec(payload.clone());
|
||||
@@ -21013,310 +21008,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn seed_retained_null_version(
|
||||
store: &Arc<crate::store::ECStore>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
protection: &str,
|
||||
latest_is_marker: bool,
|
||||
) {
|
||||
let metadata = if protection == "legal-hold" {
|
||||
HashMap::from([("x-amz-object-lock-legal-hold".to_string(), "ON".to_string())])
|
||||
} else {
|
||||
HashMap::from([
|
||||
("x-amz-object-lock-mode".to_string(), protection.to_string()),
|
||||
("x-amz-object-lock-retain-until-date".to_string(), "2099-01-01T00:00:00Z".to_string()),
|
||||
])
|
||||
};
|
||||
let mut original = PutObjReader::from_vec(b"protected null version".to_vec());
|
||||
store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut original,
|
||||
&ObjectOptions {
|
||||
user_defined: metadata,
|
||||
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(1)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed retained null version");
|
||||
let mut latest = PutObjReader::from_vec(b"unprotected latest version".to_vec());
|
||||
store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut latest,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed newer unprotected UUID version");
|
||||
if latest_is_marker {
|
||||
store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create a UUID delete marker above the retained null version");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn assert_retained_null_version_body(store: &Arc<crate::store::ECStore>, bucket: &str, object: &str) {
|
||||
let mut reader = store
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(Uuid::nil().to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("retained null version must remain readable");
|
||||
let mut body = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.expect("read retained null version");
|
||||
assert_eq!(body, b"protected null version");
|
||||
}
|
||||
|
||||
/// A versionless delete removes the null version, so the Object Lock check
|
||||
/// must judge that version even when a UUID version or delete marker is
|
||||
/// the key's latest entry (rustfs/backlog#3017).
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn null_version_object_lock_delete_checks_actual_target() {
|
||||
use crate::bucket::replication::DeleteReplicationConfigSnapshot;
|
||||
use s3s::dto::{BucketVersioningStatus, ExcludedPrefix, VersioningConfiguration};
|
||||
|
||||
let temp = tempfile::tempdir().expect("create null-version delete store");
|
||||
let (_ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp.path(), "null-version-delete-lock", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("null-delete-lock-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create Object Lock bucket");
|
||||
let configs = [
|
||||
VersioningConfiguration::default(),
|
||||
VersioningConfiguration {
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)),
|
||||
..Default::default()
|
||||
},
|
||||
VersioningConfiguration {
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
excluded_prefixes: Some(vec![ExcludedPrefix {
|
||||
prefix: Some("archive/".to_string()),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
for (case, config) in configs.into_iter().enumerate() {
|
||||
let snapshot = Arc::new(DeleteReplicationConfigSnapshot::from_configs_for_test(config, None));
|
||||
for protection in ["COMPLIANCE", "GOVERNANCE", "legal-hold"] {
|
||||
for latest_is_marker in [false, true] {
|
||||
let object = format!("archive/{case}-{protection}-{latest_is_marker}");
|
||||
seed_retained_null_version(&store, &bucket, &object, protection, latest_is_marker).await;
|
||||
let (_, errors) = store
|
||||
.delete_objects(
|
||||
&bucket,
|
||||
vec![ObjectToDelete {
|
||||
object_name: object.clone(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&snapshot)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(errors.as_slice(), [Some(StorageError::PrefixAccessDenied(_, _))]),
|
||||
"batch delete must judge the retained null version: {object}: {errors:?}"
|
||||
);
|
||||
let error = store
|
||||
.delete_object(
|
||||
&bucket,
|
||||
&object,
|
||||
ObjectOptions {
|
||||
version_suspended: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("suspended single delete must also judge the retained null version");
|
||||
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
|
||||
assert_retained_null_version_body(&store, &bucket, &object).await;
|
||||
if protection == "GOVERNANCE" {
|
||||
let (_, errors) = store
|
||||
.delete_objects(
|
||||
&bucket,
|
||||
vec![ObjectToDelete {
|
||||
object_name: object.clone(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&snapshot)),
|
||||
object_lock_delete: Some(crate::storage_api_contracts::object::ObjectLockDeleteOptions {
|
||||
bypass_governance: true,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(errors.iter().all(Option::is_none), "authorized governance bypass: {errors:?}");
|
||||
let removed = store
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
&object,
|
||||
&ObjectOptions {
|
||||
version_id: Some(Uuid::nil().to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match removed {
|
||||
Ok(marker) => {
|
||||
assert!(marker.delete_marker, "only a null delete marker may replace the payload");
|
||||
assert_eq!(marker.size, 0);
|
||||
assert!(marker.data_dir.is_none());
|
||||
}
|
||||
Err(error) => assert!(
|
||||
matches!(
|
||||
error,
|
||||
StorageError::MethodNotAllowed
|
||||
| StorageError::ObjectNotFound(_, _)
|
||||
| StorageError::VersionNotFound(_, _, _)
|
||||
),
|
||||
"unexpected bypass result: {error:?}"
|
||||
),
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
&object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(Uuid::nil().to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"authorized deletion must make the retained null payload unreadable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A versionless PUT or multipart completion replaces the null version in
|
||||
/// place, so the commit-time WORM gate must judge that version even when a
|
||||
/// UUID version or delete marker is the key's latest entry
|
||||
/// (rustfs/backlog#3019).
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn null_version_object_lock_put_and_multipart_preserve_retained_target() {
|
||||
let temp = tempfile::tempdir().expect("create null-version overwrite store");
|
||||
let (_ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp.path(), "null-version-overwrite-lock", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("null-overwrite-lock-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create Object Lock bucket");
|
||||
for protection in ["COMPLIANCE", "GOVERNANCE", "legal-hold"] {
|
||||
for latest_is_marker in [false, true] {
|
||||
let object = format!("{protection}-{latest_is_marker}");
|
||||
seed_retained_null_version(&store, &bucket, &object, protection, latest_is_marker).await;
|
||||
let mut replacement = PutObjReader::from_vec(b"replacement".to_vec());
|
||||
let error = store
|
||||
.put_object(&bucket, &object, &mut replacement, &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("PUT must reject replacement of a retained null version");
|
||||
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
|
||||
|
||||
let upload = store
|
||||
.clone()
|
||||
.new_multipart_upload(&bucket, &object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("initiate multipart upload");
|
||||
let mut part_reader = PutObjReader::from_vec(b"multipart replacement".to_vec());
|
||||
let part = store
|
||||
.clone()
|
||||
.put_object_part(&bucket, &object, &upload.upload_id, 1, &mut part_reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("stage multipart replacement");
|
||||
let error = store
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
&bucket,
|
||||
&object,
|
||||
&upload.upload_id,
|
||||
vec![crate::storage_api_contracts::multipart::CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("multipart completion must reject replacement of a retained null version");
|
||||
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
|
||||
assert_retained_null_version_body(&store, &bucket, &object).await;
|
||||
|
||||
let mut next_version = PutObjReader::from_vec(b"new UUID version".to_vec());
|
||||
store
|
||||
.put_object(
|
||||
&bucket,
|
||||
&object,
|
||||
&mut next_version,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a new UUID version must remain allowed");
|
||||
assert_retained_null_version_body(&store, &bucket, &object).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn exact_version_put_uses_only_its_own_bucket_object_lock_snapshot() {
|
||||
@@ -27662,4 +27353,282 @@ mod tests {
|
||||
.expect("recovery must preserve the local source object");
|
||||
assert_eq!(source_after.size, i64::try_from(payload.len()).expect("payload length should fit i64"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn seed_retained_null_version(
|
||||
store: &Arc<crate::store::ECStore>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
protection: &str,
|
||||
latest_is_marker: bool,
|
||||
) {
|
||||
let metadata = if protection == "legal-hold" {
|
||||
HashMap::from([("x-amz-object-lock-legal-hold".to_string(), "ON".to_string())])
|
||||
} else {
|
||||
HashMap::from([
|
||||
("x-amz-object-lock-mode".to_string(), protection.to_string()),
|
||||
("x-amz-object-lock-retain-until-date".to_string(), "2099-01-01T00:00:00Z".to_string()),
|
||||
])
|
||||
};
|
||||
let mut original = PutObjReader::from_vec(b"protected null version".to_vec());
|
||||
store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut original,
|
||||
&ObjectOptions {
|
||||
user_defined: metadata,
|
||||
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(1)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed retained null version");
|
||||
let mut latest = PutObjReader::from_vec(b"unprotected latest version".to_vec());
|
||||
store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut latest,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed newer unprotected UUID version");
|
||||
if latest_is_marker {
|
||||
store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create a UUID delete marker above the retained null version");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn assert_retained_null_version_body(store: &Arc<crate::store::ECStore>, bucket: &str, object: &str) {
|
||||
let mut reader = store
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(Uuid::nil().to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("retained null version must remain readable");
|
||||
let mut body = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.expect("read retained null version");
|
||||
assert_eq!(body, b"protected null version");
|
||||
}
|
||||
|
||||
/// A versionless delete removes the null version, so the Object Lock check
|
||||
/// must judge that version even when a UUID version or delete marker is
|
||||
/// the key's latest entry (rustfs/backlog#3017).
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn null_version_object_lock_delete_checks_actual_target() {
|
||||
use crate::bucket::replication::DeleteReplicationConfigSnapshot;
|
||||
use s3s::dto::{BucketVersioningStatus, ExcludedPrefix, VersioningConfiguration};
|
||||
|
||||
let temp = tempfile::tempdir().expect("create null-version delete store");
|
||||
let (_ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp.path(), "null-version-delete-lock", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("null-delete-lock-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create Object Lock bucket");
|
||||
let configs = [
|
||||
VersioningConfiguration::default(),
|
||||
VersioningConfiguration {
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)),
|
||||
..Default::default()
|
||||
},
|
||||
VersioningConfiguration {
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
excluded_prefixes: Some(vec![ExcludedPrefix {
|
||||
prefix: Some("archive/".to_string()),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
for (case, config) in configs.into_iter().enumerate() {
|
||||
let snapshot = Arc::new(DeleteReplicationConfigSnapshot::from_configs_for_test(config, None));
|
||||
for protection in ["COMPLIANCE", "GOVERNANCE", "legal-hold"] {
|
||||
for latest_is_marker in [false, true] {
|
||||
let object = format!("archive/{case}-{protection}-{latest_is_marker}");
|
||||
seed_retained_null_version(&store, &bucket, &object, protection, latest_is_marker).await;
|
||||
let (_, errors) = store
|
||||
.delete_objects(
|
||||
&bucket,
|
||||
vec![ObjectToDelete {
|
||||
object_name: object.clone(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&snapshot)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(errors.as_slice(), [Some(StorageError::PrefixAccessDenied(_, _))]),
|
||||
"batch delete must judge the retained null version: {object}: {errors:?}"
|
||||
);
|
||||
let error = store
|
||||
.delete_object(
|
||||
&bucket,
|
||||
&object,
|
||||
ObjectOptions {
|
||||
version_suspended: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("suspended single delete must also judge the retained null version");
|
||||
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
|
||||
assert_retained_null_version_body(&store, &bucket, &object).await;
|
||||
if protection == "GOVERNANCE" {
|
||||
let (_, errors) = store
|
||||
.delete_objects(
|
||||
&bucket,
|
||||
vec![ObjectToDelete {
|
||||
object_name: object.clone(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&snapshot)),
|
||||
object_lock_delete: Some(crate::storage_api_contracts::object::ObjectLockDeleteOptions {
|
||||
bypass_governance: true,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(errors.iter().all(Option::is_none), "authorized governance bypass: {errors:?}");
|
||||
assert!(
|
||||
store
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
&object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(Uuid::nil().to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"authorized deletion must make the retained null payload unreadable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A versionless PUT or multipart completion replaces the null version in
|
||||
/// place, so the commit-time WORM gate must judge that version even when a
|
||||
/// UUID version or delete marker is the key's latest entry
|
||||
/// (rustfs/backlog#3019).
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn null_version_object_lock_put_and_multipart_preserve_retained_target() {
|
||||
let temp = tempfile::tempdir().expect("create null-version overwrite store");
|
||||
let (_ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp.path(), "null-version-overwrite-lock", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("null-overwrite-lock-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create Object Lock bucket");
|
||||
for protection in ["COMPLIANCE", "GOVERNANCE", "legal-hold"] {
|
||||
for latest_is_marker in [false, true] {
|
||||
let object = format!("{protection}-{latest_is_marker}");
|
||||
seed_retained_null_version(&store, &bucket, &object, protection, latest_is_marker).await;
|
||||
let mut replacement = PutObjReader::from_vec(b"replacement".to_vec());
|
||||
let error = store
|
||||
.put_object(&bucket, &object, &mut replacement, &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("PUT must reject replacement of a retained null version");
|
||||
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
|
||||
|
||||
let upload = store
|
||||
.clone()
|
||||
.new_multipart_upload(&bucket, &object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("initiate multipart upload");
|
||||
let mut part_reader = PutObjReader::from_vec(b"multipart replacement".to_vec());
|
||||
let part = store
|
||||
.clone()
|
||||
.put_object_part(&bucket, &object, &upload.upload_id, 1, &mut part_reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("stage multipart replacement");
|
||||
let error = store
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
&bucket,
|
||||
&object,
|
||||
&upload.upload_id,
|
||||
vec![crate::storage_api_contracts::multipart::CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("multipart completion must reject replacement of a retained null version");
|
||||
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
|
||||
assert_retained_null_version_body(&store, &bucket, &object).await;
|
||||
|
||||
let mut next_version = PutObjReader::from_vec(b"new UUID version".to_vec());
|
||||
store
|
||||
.put_object(
|
||||
&bucket,
|
||||
&object,
|
||||
&mut next_version,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a new UUID version must remain allowed");
|
||||
assert_retained_null_version_body(&store, &bucket, &object).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,10 +318,7 @@ mod multipart_copy_heal_tests {
|
||||
let mut corrupt = original.clone();
|
||||
corrupt[0] ^= 0x80;
|
||||
std::fs::write(&shard, &corrupt).expect("actual bitrot corruption in shared physical shard");
|
||||
// Windows only flushes a handle opened for writing.
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&shard)
|
||||
std::fs::File::open(&shard)
|
||||
.expect("open corrupted actual shard")
|
||||
.sync_all()
|
||||
.expect("persist actual fault bytes");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -423,6 +423,8 @@ fn should_enqueue_transition_immediately(oi: &ObjectInfo) -> bool {
|
||||
!is_meta_bucketname(&oi.bucket)
|
||||
}
|
||||
|
||||
const MAX_UPLOADS_LIST: usize = 10000;
|
||||
|
||||
mod bucket;
|
||||
mod bucket_fence;
|
||||
pub(crate) use bucket::await_bucket_namespace_operation;
|
||||
|
||||
@@ -273,28 +273,6 @@ async fn pause_data_movement_multipart_before_selected_completion(bucket: &str)
|
||||
}
|
||||
}
|
||||
|
||||
async fn pool_has_multipart_upload_for_key(
|
||||
pool: &crate::core::sets::Sets,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
expected_incarnation_id: Option<Uuid>,
|
||||
) -> Result<bool> {
|
||||
let results = stream::iter(pool.disk_set.iter().cloned())
|
||||
.map(|set| async move {
|
||||
set.has_multipart_upload_for_key(bucket, object, expected_incarnation_id)
|
||||
.await
|
||||
})
|
||||
.buffer_unordered(MULTIPART_LIST_SET_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
let mut found = false;
|
||||
for result in results {
|
||||
// Preserve errors from every set instead of hiding them behind a hit.
|
||||
found |= result?;
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
async fn list_pool_multipart_uploads_for_incarnation(
|
||||
pool: &crate::core::sets::Sets,
|
||||
bucket: &str,
|
||||
@@ -512,8 +490,6 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<ListPartsInfo> {
|
||||
check_list_parts_args(bucket, object, upload_id)?;
|
||||
let encoded_object = encode_dir_object_ref(object);
|
||||
let object = encoded_object.as_ref();
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
|
||||
@@ -644,8 +620,6 @@ impl ECStore {
|
||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
||||
) -> Result<(MultipartUploadResult, usize, Option<Uuid>)> {
|
||||
check_new_object_args(bucket, object, opts.data_movement)?;
|
||||
let encoded_object = encode_dir_object_ref(object);
|
||||
let object = encoded_object.as_ref();
|
||||
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
|
||||
if self.single_pool() {
|
||||
@@ -701,7 +675,21 @@ impl ECStore {
|
||||
if self.is_suspended(idx).await || self.is_pool_rebalancing(idx).await {
|
||||
continue;
|
||||
}
|
||||
if pool_has_multipart_upload_for_key(pool, bucket, object, opts.expected_bucket_incarnation_id).await? {
|
||||
let res = list_pool_multipart_uploads_for_incarnation(
|
||||
pool,
|
||||
bucket,
|
||||
&MultipartUploadListRequest {
|
||||
prefix: object.to_string(),
|
||||
key_marker: None,
|
||||
upload_id_marker: None,
|
||||
delimiter: None,
|
||||
max_uploads: MAX_UPLOADS_LIST,
|
||||
expected_incarnation_id: opts.expected_bucket_incarnation_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !res.uploads.is_empty() {
|
||||
self.apply_decommission_target_mutation_fence(idx, object, &mut opts, mutation_fence)
|
||||
.await;
|
||||
let expected_bucket_incarnation_id = opts.expected_bucket_incarnation_id;
|
||||
@@ -784,8 +772,6 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<PartInfo> {
|
||||
check_put_object_part_args(bucket, object, upload_id)?;
|
||||
let encoded_object = encode_dir_object_ref(object);
|
||||
let object = encoded_object.as_ref();
|
||||
let (mut opts, bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
if let Some(guard) = bucket_lifecycle_guard {
|
||||
opts.bucket_lifecycle_lock_owner = Some(Arc::new(guard));
|
||||
@@ -820,8 +806,6 @@ impl ECStore {
|
||||
.part_number
|
||||
.ok_or_else(|| Error::other("targeted multipart upload requires a part number"))?;
|
||||
check_put_object_part_args(bucket, object, upload_id)?;
|
||||
let encoded_object = encode_dir_object_ref(object);
|
||||
let object = encoded_object.as_ref();
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart upload requires data_movement options"));
|
||||
}
|
||||
@@ -854,8 +838,6 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<MultipartInfo> {
|
||||
check_list_parts_args(bucket, object, upload_id)?;
|
||||
let encoded_object = encode_dir_object_ref(object);
|
||||
let object = encoded_object.as_ref();
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
if self.single_pool() {
|
||||
@@ -889,8 +871,6 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
check_abort_multipart_args(bucket, object, upload_id)?;
|
||||
let encoded_object = encode_dir_object_ref(object);
|
||||
let object = encoded_object.as_ref();
|
||||
let (opts, bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
|
||||
for pool_idx in self.existing_multipart_pool_order().await {
|
||||
@@ -1072,8 +1052,6 @@ impl ECStore {
|
||||
for upload_id in upload_ids {
|
||||
check_abort_multipart_args(bucket, object, upload_id)?;
|
||||
}
|
||||
let encoded_object = encode_dir_object_ref(object);
|
||||
let object = encoded_object.as_ref();
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart abort requires data_movement options"));
|
||||
}
|
||||
@@ -1368,8 +1346,6 @@ impl ECStore {
|
||||
let request_opts = opts;
|
||||
let (target_pool_idx, mutation_fence) = target;
|
||||
check_complete_multipart_args(bucket, object, upload_id)?;
|
||||
let encoded_object = encode_dir_object_ref(object);
|
||||
let object = encoded_object.as_ref();
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart completion requires data_movement options"));
|
||||
}
|
||||
@@ -1491,362 +1467,6 @@ mod tests {
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_multipart_pool_selection_uses_only_an_exact_key_upload() {
|
||||
use crate::bucket::metadata_sys::init_bucket_metadata_sys;
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
let mut pools = Vec::new();
|
||||
for pool_index in 0..2 {
|
||||
let mut endpoints = Vec::new();
|
||||
for disk_index in 0..4 {
|
||||
let directory = tempfile::tempdir().expect("temporary disk");
|
||||
let mut endpoint = Endpoint::try_from(directory.path().to_str().expect("disk path")).expect("local endpoint");
|
||||
endpoint.set_pool_index(pool_index);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
dirs.push(directory);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
pools.push(PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: format!("exact-multipart-pool-{pool_index}"),
|
||||
platform: "test".to_owned(),
|
||||
});
|
||||
}
|
||||
let endpoints = EndpointServerPools(pools);
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
crate::store::init_local_disks_with_instance_ctx(&ctx, endpoints.clone())
|
||||
.await
|
||||
.expect("initialize local disks");
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
let store = ECStore::new_with_instance_ctx("127.0.0.1:0".parse().expect("test address"), endpoints, cancel.clone(), ctx)
|
||||
.await
|
||||
.expect("initialize two pools");
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "exact-pool-selection";
|
||||
let other_bucket = "other-pool-selection";
|
||||
for bucket in [bucket, other_bucket] {
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
}
|
||||
for (upload_bucket, key) in [(bucket, "report-long"), (bucket, "report/"), (other_bucket, "report")] {
|
||||
let (opts, _guard) = store
|
||||
.guard_multipart_bucket_incarnation(upload_bucket, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("pin upload incarnation");
|
||||
store.pools[0]
|
||||
.new_multipart_upload(upload_bucket, &encode_dir_object(key), &opts)
|
||||
.await
|
||||
.expect("seed nonmatching upload in first pool");
|
||||
}
|
||||
let (opts, _guard) = store
|
||||
.guard_multipart_bucket_incarnation(bucket, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("pin target incarnation");
|
||||
assert!(
|
||||
!pool_has_multipart_upload_for_key(&store.pools[0], bucket, "report", opts.expected_bucket_incarnation_id)
|
||||
.await
|
||||
.expect("first pool contains only other keys")
|
||||
);
|
||||
store.pools[1]
|
||||
.new_multipart_upload(bucket, "report", &opts)
|
||||
.await
|
||||
.expect("seed exact upload in second pool");
|
||||
let (created, pool_index, incarnation) = store
|
||||
.handle_new_multipart_upload_with_pool_idx(bucket, "report", &ObjectOptions::default(), None)
|
||||
.await
|
||||
.expect("create another upload for the exact key");
|
||||
assert_eq!(pool_index, 1, "prefix matches in the first pool must not change placement");
|
||||
assert_eq!(incarnation, opts.expected_bucket_incarnation_id);
|
||||
assert!(!created.upload_id.is_empty());
|
||||
assert!(
|
||||
!pool_has_multipart_upload_for_key(&store.pools[0], bucket, "report", incarnation)
|
||||
.await
|
||||
.expect("new upload must not appear in first pool")
|
||||
);
|
||||
assert!(
|
||||
!pool_has_multipart_upload_for_key(&store.pools[1], bucket, "report", Some(Uuid::new_v4()))
|
||||
.await
|
||||
.expect("old bucket incarnations must not affect placement")
|
||||
);
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_directory_keys_preserve_the_bare_object_and_round_trip() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::object::ObjectIO as _;
|
||||
use sha2::Digest;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
for versioned in [false, true] {
|
||||
let bucket = if versioned {
|
||||
"directory-mpu-versioned"
|
||||
} else {
|
||||
"directory-mpu-unversioned"
|
||||
};
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
if versioned {
|
||||
store
|
||||
.update_bucket_metadata_config(
|
||||
bucket,
|
||||
"versioning.xml",
|
||||
br#"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"#.to_vec(),
|
||||
)
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
}
|
||||
let opts = ObjectOptions {
|
||||
versioned,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
};
|
||||
let original = store
|
||||
.put_object(
|
||||
bucket,
|
||||
"protected",
|
||||
&mut PutObjReader::from_vec(b"private original".to_vec()),
|
||||
&ObjectOptions {
|
||||
user_defined: HashMap::from([(
|
||||
rustfs_utils::http::headers::AMZ_OBJECT_TAGGING.to_string(),
|
||||
"classification=secret".to_string(),
|
||||
)]),
|
||||
..opts.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write protected bare object");
|
||||
let upload = store
|
||||
.new_multipart_upload(bucket, "protected/", &opts)
|
||||
.await
|
||||
.expect("create directory upload");
|
||||
let part = store
|
||||
.put_object_part(
|
||||
bucket,
|
||||
"protected/",
|
||||
&upload.upload_id,
|
||||
1,
|
||||
&mut PutObjReader::from_vec(b"directory payload".to_vec()),
|
||||
&opts,
|
||||
)
|
||||
.await
|
||||
.expect("write directory part");
|
||||
store
|
||||
.put_object_part(
|
||||
bucket,
|
||||
"protected/",
|
||||
&upload.upload_id,
|
||||
2,
|
||||
&mut PutObjReader::from_vec(b"unselected private bytes".to_vec()),
|
||||
&opts,
|
||||
)
|
||||
.await
|
||||
.expect("write an unselected part");
|
||||
let upload_sha = hex_simd::encode_to_string(
|
||||
sha2::Sha256::digest(format!("{bucket}/protected__XLDIR__").as_bytes()),
|
||||
hex_simd::AsciiCase::Lower,
|
||||
);
|
||||
let upload_uuid = crate::runtime::sources::upload_uuid_suffix(&upload.upload_id);
|
||||
for dir in &dirs {
|
||||
let staging = dir
|
||||
.path()
|
||||
.join(crate::disk::RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&upload_sha)
|
||||
.join(&upload_uuid);
|
||||
let metadata = rustfs_filemeta::FileMeta::load(&std::fs::read(staging.join("xl.meta")).expect("upload metadata"))
|
||||
.expect("decode upload metadata");
|
||||
let versions = metadata
|
||||
.get_file_info_versions(bucket, "protected__XLDIR__", true)
|
||||
.expect("upload file info");
|
||||
let data_dir = versions
|
||||
.versions
|
||||
.first()
|
||||
.expect("upload version")
|
||||
.data_dir
|
||||
.expect("staging data directory");
|
||||
assert!(
|
||||
staging.join(data_dir.to_string()).join("part.2").is_file(),
|
||||
"unselected part exists before completion"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
store
|
||||
.get_multipart_info(bucket, "protected/", &upload.upload_id, &opts)
|
||||
.await
|
||||
.expect("upload info")
|
||||
.object,
|
||||
"protected/"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_object_parts(bucket, "protected/", &upload.upload_id, None, 10, &opts)
|
||||
.await
|
||||
.expect("list parts")
|
||||
.object,
|
||||
"protected/"
|
||||
);
|
||||
let uploads = store
|
||||
.list_multipart_uploads(bucket, "protected/", None, None, None, 10)
|
||||
.await
|
||||
.expect("list logical prefix");
|
||||
assert_eq!(uploads.uploads.len(), 1);
|
||||
assert_eq!(uploads.uploads[0].object, "protected/");
|
||||
assert_eq!(crate::runtime::sources::upload_uuid_suffix(&uploads.uploads[0].upload_id), upload_uuid,);
|
||||
let completed = store
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
bucket,
|
||||
"protected/",
|
||||
&upload.upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&opts,
|
||||
)
|
||||
.await
|
||||
.expect("complete directory upload");
|
||||
assert_eq!(completed.name, "protected/");
|
||||
for (key, expected) in [
|
||||
("protected", b"private original".as_slice()),
|
||||
("protected/", b"directory payload".as_slice()),
|
||||
] {
|
||||
let mut reader = store
|
||||
.get_object_reader(bucket, key, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("read distinct object");
|
||||
let mut bytes = Vec::new();
|
||||
reader.stream.read_to_end(&mut bytes).await.expect("read body");
|
||||
assert_eq!(bytes, expected);
|
||||
}
|
||||
let preserved = store
|
||||
.get_object_info(bucket, "protected", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("bare metadata");
|
||||
assert_eq!(preserved.version_id, original.version_id);
|
||||
assert_eq!(preserved.etag, original.etag);
|
||||
assert_eq!(preserved.user_tags.as_str(), "classification=secret");
|
||||
for dir in &dirs {
|
||||
assert!(dir.path().join(bucket).join("protected/xl.meta").is_file());
|
||||
assert!(dir.path().join(bucket).join("protected__XLDIR__/xl.meta").is_file());
|
||||
let data = dir
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join("protected__XLDIR__")
|
||||
.join(completed.data_dir.expect("multipart data directory").to_string());
|
||||
assert!(data.join("part.1").is_file());
|
||||
assert!(!data.join("part.2").exists(), "unselected part must never be published");
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join(crate::disk::RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&upload_sha)
|
||||
.join(&upload_uuid)
|
||||
.exists(),
|
||||
"original staging must be cleaned"
|
||||
);
|
||||
}
|
||||
let abandoned = store
|
||||
.new_multipart_upload(bucket, "protected/", &opts)
|
||||
.await
|
||||
.expect("create abort control");
|
||||
store
|
||||
.abort_multipart_upload(bucket, "protected/", &abandoned.upload_id, &opts)
|
||||
.await
|
||||
.expect("abort encoded upload");
|
||||
assert!(
|
||||
store
|
||||
.get_multipart_info(bucket, "protected/", &abandoned.upload_id, &opts)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_object_info(bucket, "protected/", &ObjectOptions::default())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn new_control_character_keys_are_rejected_without_trapping_legacy_objects() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::object::ObjectIO as _;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "xml-key-admission";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
for byte in (1u8..32).filter(|byte| !matches!(byte, 9 | 10 | 13)) {
|
||||
let key = format!("bad-{}", char::from(byte));
|
||||
assert!(
|
||||
store
|
||||
.put_object(bucket, &key, &mut PutObjReader::from_vec(b"body".to_vec()), &ObjectOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.new_multipart_upload(bucket, &key, &ObjectOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for dir in &dirs {
|
||||
assert!(!dir.path().join(bucket).join(&key).exists());
|
||||
}
|
||||
}
|
||||
let legacy_key = "legacy-\u{1}";
|
||||
store.pools[0]
|
||||
.get_disks_by_key(legacy_key)
|
||||
.put_object(
|
||||
bucket,
|
||||
legacy_key,
|
||||
&mut PutObjReader::from_vec(b"legacy payload".to_vec()),
|
||||
&ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed a pre-upgrade object below new-key admission");
|
||||
let mut reader = store
|
||||
.get_object_reader(bucket, legacy_key, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("legacy GET stays usable");
|
||||
let mut bytes = Vec::new();
|
||||
reader.stream.read_to_end(&mut bytes).await.expect("legacy bytes");
|
||||
assert_eq!(bytes, b"legacy payload");
|
||||
drop(reader);
|
||||
store
|
||||
.delete_object(bucket, legacy_key, ObjectOptions::default())
|
||||
.await
|
||||
.expect("legacy DELETE stays usable");
|
||||
assert!(
|
||||
store
|
||||
.get_object_info(bucket, legacy_key, &ObjectOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn mp(object: &str, upload_id: &str) -> MultipartInfo {
|
||||
MultipartInfo {
|
||||
bucket: "bucket".to_string(),
|
||||
|
||||
+148
-417
@@ -522,29 +522,16 @@ async fn pause_prefix_delete_after_journal_prepare(bucket: &str, prefix: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// Construct only the selected operation off the caller's poll frame. A plain prefix
|
||||
// delete must not carry the journal state machine into the nested per-disk delete.
|
||||
#[inline(never)]
|
||||
fn delete_prefix_with_tier_delete_journal<'a>(
|
||||
store: &'a ECStore,
|
||||
bucket: &'a str,
|
||||
object: &'a str,
|
||||
opts: &'a ObjectOptions,
|
||||
tier_journal_api: Option<&'a Arc<ECStore>>,
|
||||
) -> futures::future::BoxFuture<'a, Result<()>> {
|
||||
match tier_journal_api {
|
||||
Some(api) => Box::pin(delete_prefix_with_active_tier_journal(store, bucket, object, opts, api)),
|
||||
None => Box::pin(store.delete_prefix(bucket, object, opts)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_prefix_with_active_tier_journal(
|
||||
async fn delete_prefix_with_tier_delete_journal(
|
||||
store: &ECStore,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
api: &Arc<ECStore>,
|
||||
tier_journal_api: Option<&Arc<ECStore>>,
|
||||
) -> Result<()> {
|
||||
let Some(api) = tier_journal_api else {
|
||||
return store.delete_prefix(bucket, object, opts).await;
|
||||
};
|
||||
let PreparedPrefixTierDelete {
|
||||
dispatch,
|
||||
chunk_parent_active,
|
||||
@@ -1398,7 +1385,7 @@ impl Drop for ObjectLockDiagGuard {
|
||||
}
|
||||
|
||||
/// A failure to preserve one object generation for a SelectObjectContent read.
|
||||
#[derive(Debug, Clone, Copy, thiserror::Error)]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum SnapshotConsistencyError {
|
||||
#[error("namespace locking is disabled for SelectObjectContent")]
|
||||
@@ -1512,11 +1499,9 @@ impl SelectObjectSnapshotLease {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque object generation used by SelectObjectContent. Each range reacquires
|
||||
/// short namespace read fences and rejects changed read semantics before opening
|
||||
/// its generation-bound reader. An idle snapshot never blocks object mutations.
|
||||
/// Opaque, lock-backed object generation used by SelectObjectContent.
|
||||
pub struct SelectObjectSnapshot {
|
||||
store: Arc<ECStore>,
|
||||
pool: Arc<Sets>,
|
||||
bucket: String,
|
||||
object: String,
|
||||
headers: HeaderMap,
|
||||
@@ -1524,7 +1509,7 @@ pub struct SelectObjectSnapshot {
|
||||
object_info: ObjectInfo,
|
||||
logical_size: u64,
|
||||
read_semantics_identity: [u8; 32],
|
||||
failure: ParkingMutex<Option<SnapshotConsistencyError>>,
|
||||
first_metadata: ParkingMutex<Option<crate::set_disk::PreparedGetObjectMetadata>>,
|
||||
lease: Arc<SelectObjectSnapshotLease>,
|
||||
}
|
||||
|
||||
@@ -1557,9 +1542,6 @@ impl SelectObjectSnapshot {
|
||||
}
|
||||
|
||||
pub fn ensure_valid(&self) -> std::result::Result<(), SnapshotConsistencyError> {
|
||||
if let Some(failure) = *self.failure.lock() {
|
||||
return Err(failure);
|
||||
}
|
||||
self.lease.check()
|
||||
}
|
||||
|
||||
@@ -1573,72 +1555,38 @@ impl SelectObjectSnapshot {
|
||||
&self,
|
||||
range: Option<HTTPRangeSpec>,
|
||||
) -> std::result::Result<GetObjectReader, SelectObjectSnapshotReadError> {
|
||||
let result = self.open_generation_reader(range).await;
|
||||
if let Err(SelectObjectSnapshotReadError::Consistency(failure)) = &result {
|
||||
*self.failure.lock() = Some(*failure);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn open_generation_reader(
|
||||
&self,
|
||||
range: Option<HTTPRangeSpec>,
|
||||
) -> std::result::Result<GetObjectReader, SelectObjectSnapshotReadError> {
|
||||
self.ensure_valid()?;
|
||||
if self.store.ctx.lock_manager().is_disabled() {
|
||||
return Err(SnapshotConsistencyError::LockingDisabled.into());
|
||||
}
|
||||
let mut opts = self.opts.clone();
|
||||
opts.no_lock = false;
|
||||
let guards = self
|
||||
.store
|
||||
.acquire_all_object_read_locks("select_object_range", &self.bucket, &self.object, &mut opts)
|
||||
.await?;
|
||||
let pin_lease = SelectObjectSnapshotLease::new(guards);
|
||||
pin_lease.check()?;
|
||||
#[cfg(test)]
|
||||
tests::pause_select_range_after_lock(&self.bucket, &self.object).await;
|
||||
// Re-select across all pools, including on the first range. Metadata
|
||||
// captured during prepare is no longer protected after that call returns.
|
||||
let (metadata, pool) = self
|
||||
.store
|
||||
.prepare_select_object_metadata(&self.bucket, &self.object, &opts)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if is_err_object_not_found(&error)
|
||||
|| is_err_version_not_found(&error)
|
||||
|| matches!(&error, StorageError::BucketNotFound(_) | StorageError::VolumeNotFound)
|
||||
{
|
||||
SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged)
|
||||
} else {
|
||||
SelectObjectSnapshotReadError::Storage(error)
|
||||
}
|
||||
})?;
|
||||
pin_lease.check()?;
|
||||
self.lease.check()?;
|
||||
let first_metadata = self.first_metadata.lock().take();
|
||||
let metadata = match first_metadata {
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
self.pool
|
||||
.prepare_get_object_reader_metadata(&self.bucket, &self.object, &self.opts)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
self.lease.check()?;
|
||||
if metadata.read_semantics_identity() != self.read_semantics_identity {
|
||||
return Err(SnapshotConsistencyError::ObjectChanged.into());
|
||||
}
|
||||
let mut reader = crate::object_api::without_get_object_body_cache_hook(pool.get_object_reader_with_prepared_metadata(
|
||||
&self.bucket,
|
||||
&self.object,
|
||||
range,
|
||||
self.headers.clone(),
|
||||
&opts,
|
||||
metadata,
|
||||
))
|
||||
.await?;
|
||||
pin_lease.check()?;
|
||||
self.ensure_valid()?;
|
||||
|
||||
let mut reader =
|
||||
crate::object_api::without_get_object_body_cache_hook(self.pool.get_object_reader_with_prepared_metadata(
|
||||
&self.bucket,
|
||||
&self.object,
|
||||
range,
|
||||
self.headers.clone(),
|
||||
&self.opts,
|
||||
metadata,
|
||||
))
|
||||
.await?;
|
||||
self.lease.check()?;
|
||||
reader.body_source = crate::object_api::GetObjectBodySource::HookMissed;
|
||||
reader.stream = Box::new(SelectObjectSnapshotReader {
|
||||
inner: reader.stream,
|
||||
lock_loss_wake: SelectObjectSnapshotLockLossWake::new(self.lease.subscribe_lock_loss()),
|
||||
lease: Arc::clone(&self.lease),
|
||||
});
|
||||
// Streaming/lazy readers retain old FileInfo, inline bytes and data_dir
|
||||
// paths, never a current-key lookup. Cleanup can fail the read, but may
|
||||
// not select replacement bytes. This is independent of GET's lock knob.
|
||||
drop(pin_lease);
|
||||
Ok(reader)
|
||||
}
|
||||
}
|
||||
@@ -3235,7 +3183,7 @@ impl ECStore {
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn object_lock_config_snapshot_for_single_put(&self, bucket: &str) -> Result<Arc<ObjectLockConfigSnapshot>> {
|
||||
async fn object_lock_config_snapshot_for_single_put(&self, bucket: &str) -> Result<Arc<ObjectLockConfigSnapshot>> {
|
||||
check_valid_bucket_name(bucket)?;
|
||||
let lifecycle_guard = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||
let metadata_guard = acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await?;
|
||||
@@ -3284,46 +3232,13 @@ impl ECStore {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Selects authoritative metadata across all pools under the caller's locks.
|
||||
async fn prepare_select_object_metadata(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(crate::set_disk::PreparedGetObjectMetadata, Arc<Sets>)> {
|
||||
// Acquiring the namespace locks marks ordinary GETs cache-safe. A
|
||||
// SELECT generation check must instead consult the disks: another node
|
||||
// (or another SetDisks owner) can commit without invalidating this cache.
|
||||
let mut opts = opts.clone();
|
||||
opts.metadata_cache_safe = false;
|
||||
Ok(if self.single_pool() {
|
||||
let pool = Arc::clone(&self.pools[0]);
|
||||
let metadata = pool.prepare_get_object_reader_metadata(bucket, object, &opts).await?;
|
||||
(metadata, pool)
|
||||
} else {
|
||||
// Keep the large multi-pool selection future off the caller stack.
|
||||
// Debug builds otherwise exceed the common 2 MiB worker stack.
|
||||
Box::pin(async {
|
||||
let (metadata, pool_idx) = self.prepare_latest_object_metadata_with_idx(bucket, object, &opts).await?;
|
||||
let pool = self.pools.get(pool_idx).cloned().ok_or_else(|| {
|
||||
StorageError::other(format!("resolved SelectObjectContent pool index {pool_idx} is out of bounds"))
|
||||
})?;
|
||||
Ok::<_, StorageError>((metadata, pool))
|
||||
})
|
||||
.await?
|
||||
})
|
||||
}
|
||||
|
||||
/// Captures the initial generation for a SelectObjectContent query under short namespace locks.
|
||||
/// Resolves a GET's object identity without constructing its body reader.
|
||||
///
|
||||
/// Each range, including the first, reacquires these locks, revalidates the
|
||||
/// initial read semantics, and constructs a generation-bound reader before
|
||||
/// releasing them. Concurrent mutation may cause a consistency or storage
|
||||
/// error; callers must propagate it instead of reporting successful query completion.
|
||||
/// Cancelling preparation or a range open drops its guards. Returned snapshots
|
||||
/// and readers never retain namespace locks while waiting for client input.
|
||||
/// This is an additive two-stage counterpart to `get_object_reader`. The
|
||||
/// existing method remains the compatibility path for callers that do not
|
||||
/// need a pre-reader decision point.
|
||||
pub async fn prepare_select_object_snapshot(
|
||||
self: &Arc<Self>,
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
headers: &HeaderMap,
|
||||
@@ -3345,7 +3260,22 @@ impl ECStore {
|
||||
return Err(SnapshotConsistencyError::LockLost.into());
|
||||
}
|
||||
|
||||
let (mut metadata, _pool) = self.prepare_select_object_metadata(bucket, &object, &opts).await?;
|
||||
let (mut metadata, pool) = if self.single_pool() {
|
||||
let pool = Arc::clone(&self.pools[0]);
|
||||
let metadata = pool.prepare_get_object_reader_metadata(bucket, &object, &opts).await?;
|
||||
(metadata, pool)
|
||||
} else {
|
||||
// Keep the large multi-pool selection future off the caller stack.
|
||||
// Debug builds otherwise exceed the common 2 MiB worker stack.
|
||||
Box::pin(async {
|
||||
let (metadata, pool_idx) = self.prepare_latest_object_metadata_with_idx(bucket, &object, &opts).await?;
|
||||
let pool = self.pools.get(pool_idx).cloned().ok_or_else(|| {
|
||||
StorageError::other(format!("resolved SelectObjectContent pool index {pool_idx} is out of bounds"))
|
||||
})?;
|
||||
Ok::<_, StorageError>((metadata, pool))
|
||||
})
|
||||
.await?
|
||||
};
|
||||
if read_lock_guards.iter().any(ObjectLockDiagGuard::is_lock_lost) {
|
||||
return Err(SnapshotConsistencyError::LockLost.into());
|
||||
}
|
||||
@@ -3362,12 +3292,8 @@ impl ECStore {
|
||||
return Err(SnapshotConsistencyError::LockLost.into());
|
||||
}
|
||||
|
||||
// Snapshot identity survives, but preparation metadata and all namespace
|
||||
// fences are released before a caller can wait on client backpressure.
|
||||
drop(metadata);
|
||||
drop(read_lock_guards);
|
||||
Ok(SelectObjectSnapshot {
|
||||
store: Arc::clone(self),
|
||||
pool,
|
||||
bucket: bucket.to_owned(),
|
||||
object,
|
||||
headers: rustfs_utils::http::project_ssec_transport_headers(headers),
|
||||
@@ -3375,8 +3301,8 @@ impl ECStore {
|
||||
object_info,
|
||||
logical_size,
|
||||
read_semantics_identity,
|
||||
failure: ParkingMutex::new(None),
|
||||
lease: Arc::new(SelectObjectSnapshotLease::new(Vec::new())),
|
||||
first_metadata: ParkingMutex::new(Some(metadata)),
|
||||
lease: Arc::new(SelectObjectSnapshotLease::new(read_lock_guards)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4783,12 +4709,6 @@ impl ECStore {
|
||||
opts.expected_bucket_incarnation_id = Some(self.bucket_incarnation_id(bucket).await?);
|
||||
}
|
||||
if opts.overwrites_existing_version() && !is_meta_bucketname(bucket) {
|
||||
if opts.object_lock_config_snapshot.is_none() && !opts.no_lock && !opts.data_movement {
|
||||
// The request body is client-paced. Acquire configuration and
|
||||
// lifecycle guards in SetDisks only once its staging has finished.
|
||||
opts.defer_put_object_lock_snapshot = true;
|
||||
return Ok((object, opts));
|
||||
}
|
||||
let expected_incarnation_id = opts
|
||||
.expected_bucket_incarnation_id
|
||||
.ok_or_else(|| Error::other("destructive PUT is missing its bucket incarnation"))?;
|
||||
@@ -5439,12 +5359,6 @@ impl ECStore {
|
||||
) -> Result<ObjectInfo> {
|
||||
Box::pin(async move {
|
||||
let mut opts = opts;
|
||||
if opts.delete_recovery_work_budget.is_none() {
|
||||
// Reservations are taken up front per disk, so size the request
|
||||
// budget for every drive the delete can fan out to.
|
||||
let disks = self.pools.iter().map(|pool| pool.set_count * pool.set_drive_count).sum();
|
||||
opts.delete_recovery_work_budget = Some(Arc::new(crate::object_api::DeleteRecoveryWorkBudget::for_disks(disks)));
|
||||
}
|
||||
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
|
||||
let result = self
|
||||
.handle_delete_object_with_journal_inner_boxed(bucket, object, opts, tier_journal_api)
|
||||
@@ -6004,12 +5918,6 @@ impl ECStore {
|
||||
return (deleted, errors, accounting);
|
||||
}
|
||||
let mut opts = opts;
|
||||
if opts.delete_recovery_work_budget.is_none() {
|
||||
// Reservations are taken up front per disk, so size the request
|
||||
// budget for every drive the delete can fan out to.
|
||||
let disks = self.pools.iter().map(|pool| pool.set_count * pool.set_drive_count).sum();
|
||||
opts.delete_recovery_work_budget = Some(Arc::new(crate::object_api::DeleteRecoveryWorkBudget::for_disks(disks)));
|
||||
}
|
||||
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
|
||||
let (valid_deleted, valid_errors, valid_accounting) = self
|
||||
.handle_delete_objects_with_journal_and_accounting_inner(bucket, valid_objects, opts, tier_journal_api)
|
||||
@@ -12040,63 +11948,6 @@ mod tests {
|
||||
assert!(other.is_empty());
|
||||
}
|
||||
|
||||
struct SelectRangeLockBarrier {
|
||||
bucket: String,
|
||||
object: String,
|
||||
reached: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
type SelectRangeBarriers = ParkingMutex<HashMap<(String, String), Arc<SelectRangeLockBarrier>>>;
|
||||
|
||||
fn select_range_barriers() -> &'static SelectRangeBarriers {
|
||||
static BARRIERS: std::sync::OnceLock<SelectRangeBarriers> = std::sync::OnceLock::new();
|
||||
BARRIERS.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
struct SelectRangeBarrierGuard(Arc<SelectRangeLockBarrier>);
|
||||
|
||||
impl SelectRangeBarrierGuard {
|
||||
fn install(bucket: &str, object: &str) -> Self {
|
||||
let barrier = Arc::new(SelectRangeLockBarrier {
|
||||
bucket: bucket.to_owned(),
|
||||
object: object.to_owned(),
|
||||
reached: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
select_range_barriers()
|
||||
.lock()
|
||||
.insert((bucket.to_owned(), object.to_owned()), Arc::clone(&barrier));
|
||||
Self(barrier)
|
||||
}
|
||||
|
||||
async fn wait(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(10), self.0.reached.notified())
|
||||
.await
|
||||
.expect("range acquired its namespace locks");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SelectRangeBarrierGuard {
|
||||
fn drop(&mut self) {
|
||||
select_range_barriers()
|
||||
.lock()
|
||||
.remove(&(self.0.bucket.clone(), self.0.object.clone()));
|
||||
self.0.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn pause_select_range_after_lock(bucket: &str, object: &str) {
|
||||
let barrier = select_range_barriers()
|
||||
.lock()
|
||||
.get(&(bucket.to_owned(), object.to_owned()))
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.reached.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_prepared_reader_blocks_writer(store: &ECStore, bucket: &str, object: &str) {
|
||||
assert_pool_writer_is_blocked(store, 0, bucket, object).await;
|
||||
}
|
||||
@@ -12237,175 +12088,76 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn select_snapshot_releases_namespace_locks_independent_of_get_optimization() {
|
||||
for optimization in ["true", "false"] {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some(optimization))], async {
|
||||
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
|
||||
let store = Arc::new(new_prepared_reader_test_store(&[set_disks]).await);
|
||||
let bucket = "select-snapshot-lock-lifetime";
|
||||
let object = "object.bin";
|
||||
let payload = b"select-snapshot-lock-lifetime-payload-".repeat(40_000);
|
||||
let put_opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
async fn select_snapshot_holds_namespace_lock_independent_of_get_optimization() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some("true"))], async {
|
||||
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
|
||||
let store = new_prepared_reader_test_store(&[set_disks]).await;
|
||||
let bucket = "select-snapshot-lock-lifetime";
|
||||
let object = "object.bin";
|
||||
let payload = b"select-snapshot-lock-lifetime-payload-".repeat(40_000);
|
||||
let put_opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
store.pools[0]
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
let mut put_reader = PutObjReader::from_vec(payload.clone());
|
||||
store.pools[0]
|
||||
.put_object(bucket, object, &mut put_reader, &put_opts)
|
||||
.await
|
||||
.expect("object should be written");
|
||||
|
||||
use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert(SSEC_ALGORITHM_HEADER, "AES256".parse().expect("valid SSE-C algorithm header"));
|
||||
request_headers.insert(SSEC_KEY_HEADER, "secret-key".parse().expect("valid SSE-C key header"));
|
||||
request_headers.insert(SSEC_KEY_MD5_HEADER, "key-md5".parse().expect("valid SSE-C key digest header"));
|
||||
request_headers.insert("authorization", "credential".parse().expect("valid authorization header"));
|
||||
let snapshot = store
|
||||
.prepare_select_object_snapshot(bucket, object, &request_headers, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("SelectObjectContent snapshot should be prepared");
|
||||
assert_eq!(snapshot.headers.len(), 3);
|
||||
assert_eq!(snapshot.headers.get(SSEC_ALGORITHM_HEADER), request_headers.get(SSEC_ALGORITHM_HEADER));
|
||||
assert_eq!(snapshot.headers.get(SSEC_KEY_HEADER), request_headers.get(SSEC_KEY_HEADER));
|
||||
assert_eq!(snapshot.headers.get(SSEC_KEY_MD5_HEADER), request_headers.get(SSEC_KEY_MD5_HEADER));
|
||||
assert!(snapshot.headers.get("authorization").is_none());
|
||||
assert!(snapshot.headers.values().all(http::HeaderValue::is_sensitive));
|
||||
assert!(!format!("{:?}", snapshot.headers).contains("secret-key"));
|
||||
assert_eq!(
|
||||
snapshot.logical_size(),
|
||||
u64::try_from(payload.len()).expect("test payload length should fit in u64")
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.object_info().size,
|
||||
i64::try_from(payload.len()).expect("test payload length should fit in i64")
|
||||
);
|
||||
drop(acquire_prepared_reader_writer(&store, bucket, object).await);
|
||||
|
||||
let mut reader = snapshot.open_reader(None).await.expect("snapshot reader should open");
|
||||
let mut restored = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut restored)
|
||||
.await
|
||||
.expect("snapshot body should stream");
|
||||
assert_eq!(restored, payload);
|
||||
drop(reader);
|
||||
drop(acquire_prepared_reader_writer(&store, bucket, object).await);
|
||||
|
||||
let mut unread = snapshot.open_reader(None).await.expect("unread SELECT range");
|
||||
let mut replacement = PutObjReader::from_vec(vec![b'z'; payload.len()]);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
store.pools[0].put_object(bucket, object, &mut replacement, &ObjectOptions::default()),
|
||||
)
|
||||
store.pools[0]
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("unread SELECT cannot block an overwrite")
|
||||
.expect("overwrite");
|
||||
let mut old_bytes = Vec::new();
|
||||
match unread.stream.read_to_end(&mut old_bytes).await {
|
||||
Ok(_) => assert_eq!(old_bytes, payload, "an opened range must never switch generations"),
|
||||
Err(_) => assert!(payload.starts_with(&old_bytes), "failed reads may emit only original bytes"),
|
||||
}
|
||||
assert!(matches!(
|
||||
snapshot.open_reader(None).await,
|
||||
Err(SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged))
|
||||
));
|
||||
assert!(
|
||||
snapshot.ensure_valid().is_err(),
|
||||
"consistency failure prevents successful terminal events"
|
||||
);
|
||||
drop(snapshot);
|
||||
drop(acquire_prepared_reader_writer(&store, bucket, object).await);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
.expect("bucket should be created");
|
||||
let mut put_reader = PutObjReader::from_vec(payload.clone());
|
||||
store.pools[0]
|
||||
.put_object(bucket, object, &mut put_reader, &put_opts)
|
||||
.await
|
||||
.expect("object should be written");
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn select_snapshot_lazy_multipart_reader_never_switches_to_replacement_parts() {
|
||||
use crate::storage_api_contracts::multipart::{CompletePart, MultipartOperations};
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
("RUSTFS_GET_CODEC_STREAMING_ENABLE", Some("true")),
|
||||
("RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED", Some("true")),
|
||||
("RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED", Some("true")),
|
||||
("RUSTFS_GET_CODEC_STREAMING_ROLLOUT", Some("on")),
|
||||
("RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE", Some("true")),
|
||||
("RUSTFS_GET_CODEC_STREAMING_MIN_SIZE", Some("1")),
|
||||
(rustfs_config::ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some("true")),
|
||||
],
|
||||
async {
|
||||
let (_dirs, set) = make_local_set_disks(4, 2).await;
|
||||
let store = Arc::new(new_prepared_reader_test_store(&[Arc::clone(&set)]).await);
|
||||
let bucket = "select-lazy-multipart-generation";
|
||||
let object = "multipart.bin";
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default()).await.expect("bucket");
|
||||
let opts = ObjectOptions::default();
|
||||
let upload = set.new_multipart_upload(bucket, object, &opts).await.expect("upload");
|
||||
let mut expected = Vec::new();
|
||||
let mut parts = Vec::new();
|
||||
for (number, length, byte) in [(1, 5 * 1024 * 1024, b'a'), (2, 1024, b'b')] {
|
||||
let bytes = vec![byte; length];
|
||||
expected.extend_from_slice(&bytes);
|
||||
let mut body = PutObjReader::from_vec(bytes);
|
||||
let part = set
|
||||
.put_object_part(bucket, object, &upload.upload_id, number, &mut body, &opts)
|
||||
.await
|
||||
.expect("part");
|
||||
parts.push(CompletePart {
|
||||
part_num: number,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Arc::clone(&set)
|
||||
.complete_multipart_upload(bucket, object, &upload.upload_id, parts, &opts)
|
||||
.await
|
||||
.expect("complete upload");
|
||||
let snapshot = store
|
||||
.prepare_select_object_snapshot(bucket, object, &HeaderMap::new(), &opts)
|
||||
.await
|
||||
.expect("snapshot");
|
||||
crate::set_disk::reset_test_get_object_reader_path();
|
||||
let mut reader = snapshot.open_reader(None).await.expect("lazy multipart reader");
|
||||
assert_eq!(
|
||||
crate::set_disk::test_get_object_reader_path_id(),
|
||||
5,
|
||||
"exercise codec streaming rather than a materialized read"
|
||||
);
|
||||
let mut first = [0u8; 1];
|
||||
reader.stream.read_exact(&mut first).await.expect("first original byte");
|
||||
assert_eq!(first, [b'a']);
|
||||
let mut replacement = PutObjReader::from_vec(vec![b'z'; expected.len()]);
|
||||
tokio::time::timeout(Duration::from_secs(10), set.put_object(bucket, object, &mut replacement, &opts))
|
||||
.await
|
||||
.expect("unread parts cannot block overwrite")
|
||||
.expect("overwrite");
|
||||
let mut body = first.to_vec();
|
||||
match reader.stream.read_to_end(&mut body).await {
|
||||
Ok(_) => assert_eq!(body, expected, "all successful bytes belong to the original MPU"),
|
||||
Err(_) => assert!(expected.starts_with(&body), "even a failed stream cannot contain replacement bytes"),
|
||||
}
|
||||
assert!(matches!(
|
||||
snapshot.open_reader(None).await,
|
||||
Err(SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged))
|
||||
));
|
||||
},
|
||||
)
|
||||
use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert(SSEC_ALGORITHM_HEADER, "AES256".parse().expect("valid SSE-C algorithm header"));
|
||||
request_headers.insert(SSEC_KEY_HEADER, "secret-key".parse().expect("valid SSE-C key header"));
|
||||
request_headers.insert(SSEC_KEY_MD5_HEADER, "key-md5".parse().expect("valid SSE-C key digest header"));
|
||||
request_headers.insert("authorization", "credential".parse().expect("valid authorization header"));
|
||||
let snapshot = store
|
||||
.prepare_select_object_snapshot(bucket, object, &request_headers, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("SelectObjectContent snapshot should be prepared");
|
||||
assert_eq!(snapshot.headers.len(), 3);
|
||||
assert_eq!(snapshot.headers.get(SSEC_ALGORITHM_HEADER), request_headers.get(SSEC_ALGORITHM_HEADER));
|
||||
assert_eq!(snapshot.headers.get(SSEC_KEY_HEADER), request_headers.get(SSEC_KEY_HEADER));
|
||||
assert_eq!(snapshot.headers.get(SSEC_KEY_MD5_HEADER), request_headers.get(SSEC_KEY_MD5_HEADER));
|
||||
assert!(snapshot.headers.get("authorization").is_none());
|
||||
assert!(snapshot.headers.values().all(http::HeaderValue::is_sensitive));
|
||||
assert!(!format!("{:?}", snapshot.headers).contains("secret-key"));
|
||||
assert_eq!(
|
||||
snapshot.logical_size(),
|
||||
u64::try_from(payload.len()).expect("test payload length should fit in u64")
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.object_info().size,
|
||||
i64::try_from(payload.len()).expect("test payload length should fit in i64")
|
||||
);
|
||||
assert_prepared_reader_blocks_writer(&store, bucket, object).await;
|
||||
|
||||
let mut reader = snapshot.open_reader(None).await.expect("snapshot reader should open");
|
||||
let mut restored = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut restored)
|
||||
.await
|
||||
.expect("snapshot body should stream");
|
||||
assert_eq!(restored, payload);
|
||||
drop(reader);
|
||||
assert_prepared_reader_blocks_writer(&store, bucket, object).await;
|
||||
|
||||
drop(snapshot);
|
||||
drop(acquire_prepared_reader_writer(&store, bucket, object).await);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn select_snapshot_identity_compares_encoded_directory_object_key() {
|
||||
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
|
||||
let store = Arc::new(new_prepared_reader_test_store(&[set_disks]).await);
|
||||
let store = new_prepared_reader_test_store(&[set_disks]).await;
|
||||
let bucket = "select-snapshot-directory-identity";
|
||||
let source_object = "source.bin";
|
||||
|
||||
@@ -12439,9 +12191,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn select_snapshot_revalidates_initial_metadata_before_first_reader() {
|
||||
async fn select_snapshot_reuses_initial_metadata_fanout_for_first_reader() {
|
||||
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
|
||||
let store = Arc::new(new_prepared_reader_test_store(&[set_disks]).await);
|
||||
let store = new_prepared_reader_test_store(&[set_disks]).await;
|
||||
let bucket = "select-snapshot-initial-metadata";
|
||||
let object = "initial-metadata.bin";
|
||||
let payload = b"select snapshot initial metadata".repeat(4_000);
|
||||
@@ -12474,8 +12226,8 @@ mod tests {
|
||||
let mut reader = snapshot.open_reader(None).await.expect("first snapshot reader should open");
|
||||
assert_eq!(
|
||||
calls.total(disk_call_counters::KIND_READ_VERSION),
|
||||
8,
|
||||
"first reader must revalidate metadata after preparation released its locks"
|
||||
4,
|
||||
"first reader should consume the metadata captured during preparation"
|
||||
);
|
||||
let mut restored = Vec::new();
|
||||
reader
|
||||
@@ -12490,7 +12242,7 @@ mod tests {
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn select_snapshot_rejects_identity_change_before_second_reader() {
|
||||
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
|
||||
let store = Arc::new(new_prepared_reader_test_store(&[Arc::clone(&set_disks)]).await);
|
||||
let store = new_prepared_reader_test_store(&[Arc::clone(&set_disks)]).await;
|
||||
let bucket = "select-snapshot-identity-change";
|
||||
let object = "identity-change.bin";
|
||||
let no_lock_opts = ObjectOptions {
|
||||
@@ -12507,7 +12259,7 @@ mod tests {
|
||||
.put_object(bucket, object, &mut initial_reader, &no_lock_opts)
|
||||
.await
|
||||
.expect("initial generation should be written");
|
||||
let snapshot = store
|
||||
let mut snapshot = store
|
||||
.prepare_select_object_snapshot(bucket, object, &HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("snapshot should be prepared");
|
||||
@@ -12517,12 +12269,13 @@ mod tests {
|
||||
.await
|
||||
.expect("first snapshot reader should consume captured metadata"),
|
||||
);
|
||||
snapshot.opts.metadata_cache_safe = false;
|
||||
|
||||
let mut replacement_reader = PutObjReader::from_vec(b"replacement generation".to_vec());
|
||||
set_disks
|
||||
.put_object(bucket, object, &mut replacement_reader, &ObjectOptions::default())
|
||||
.put_object(bucket, object, &mut replacement_reader, &no_lock_opts)
|
||||
.await
|
||||
.expect("normal writer should replace the object while the snapshot is idle");
|
||||
.expect("test-only no-lock write should replace the object");
|
||||
let error = match snapshot.open_reader(None).await {
|
||||
Ok(_) => panic!("a later reader must reject the replacement generation"),
|
||||
Err(error) => error,
|
||||
@@ -12539,8 +12292,7 @@ mod tests {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (_first_dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
let (_second_dirs, second_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
let store =
|
||||
Arc::new(new_prepared_reader_test_store_with_ctx(&[Arc::clone(&first_set), Arc::clone(&second_set)], ctx).await);
|
||||
let store = new_prepared_reader_test_store_with_ctx(&[Arc::clone(&first_set), Arc::clone(&second_set)], ctx).await;
|
||||
let bucket = "select-snapshot-latest-delete-marker";
|
||||
let object = "versioned-object.bin";
|
||||
let versioned_opts = ObjectOptions {
|
||||
@@ -12593,7 +12345,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn select_snapshot_allows_store_delete_for_object_in_nonzero_set() {
|
||||
async fn select_snapshot_blocks_store_delete_for_object_in_nonzero_set() {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (_dirs, sets) = make_local_two_set_sets_with_ctx(Arc::clone(&ctx)).await;
|
||||
ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
@@ -12633,35 +12385,37 @@ mod tests {
|
||||
sets.disk_set[1].lockers.clone(),
|
||||
2,
|
||||
);
|
||||
let writer_guard = hashed_set_writer
|
||||
let writer_error = hashed_set_writer
|
||||
.get_write_lock(
|
||||
rustfs_lock::ObjectKey::new(bucket, object.as_str()),
|
||||
"competing-hashed-set-writer",
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.await
|
||||
.expect("idle Select must release the nonzero hashed-set read lock");
|
||||
drop(writer_guard);
|
||||
.expect_err("Select must hold the nonzero hashed-set read lock");
|
||||
assert!(matches!(writer_error, rustfs_lock::LockError::Timeout { .. }));
|
||||
let barrier = DeleteAfterObjectLockSnapshotBarrier::install(bucket);
|
||||
let delete_store = Arc::clone(&store);
|
||||
let delete_object = object.clone();
|
||||
let delete = tokio::spawn(async move {
|
||||
let mut delete = tokio::spawn(async move {
|
||||
delete_store
|
||||
.delete_object(bucket, &delete_object, ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
barrier.release();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut delete).await.is_err(),
|
||||
"store DELETE must wait for the fixed-domain Select read lock"
|
||||
);
|
||||
|
||||
drop(snapshot);
|
||||
let deleted = tokio::time::timeout(Duration::from_secs(10), delete)
|
||||
.await
|
||||
.expect("DELETE should complete while the snapshot remains alive")
|
||||
.expect("DELETE should resume after the snapshot is dropped")
|
||||
.expect("DELETE task should not panic")
|
||||
.expect("DELETE should complete");
|
||||
assert_eq!(deleted.name, object);
|
||||
assert!(matches!(
|
||||
snapshot.open_reader(None).await,
|
||||
Err(SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -12671,7 +12425,7 @@ mod tests {
|
||||
rustfs_lock::GlobalLockManager::Disabled(rustfs_lock::DisabledLockManager::new()),
|
||||
)));
|
||||
let (_dirs, set_disks) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
let store = Arc::new(new_prepared_reader_test_store_with_ctx(&[set_disks], ctx).await);
|
||||
let store = new_prepared_reader_test_store_with_ctx(&[set_disks], ctx).await;
|
||||
let bucket = "select-snapshot-lock-disabled";
|
||||
let object = "object.bin";
|
||||
let mut put_reader = PutObjReader::from_vec(b"payload".to_vec());
|
||||
@@ -12783,11 +12537,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn select_snapshot_revalidates_metadata_across_three_pools() {
|
||||
async fn select_snapshot_reuses_metadata_across_three_pools() {
|
||||
let (_first_dirs, first_set) = make_local_set_disks(4, 2).await;
|
||||
let (_second_dirs, second_set) = make_local_set_disks(4, 2).await;
|
||||
let (_third_dirs, third_set) = make_local_set_disks(4, 2).await;
|
||||
let store = Arc::new(new_prepared_reader_test_store(&[first_set, second_set, third_set]).await);
|
||||
let store = new_prepared_reader_test_store(&[first_set, second_set, third_set]).await;
|
||||
let bucket = "select-snapshot-three-pools";
|
||||
let object = "object.bin";
|
||||
let payload = b"select-snapshot-three-pool-payload-".repeat(40_000);
|
||||
@@ -12819,8 +12573,8 @@ mod tests {
|
||||
let mut reader = snapshot.open_reader(None).await.expect("snapshot body reader should open");
|
||||
assert_eq!(
|
||||
calls.total(disk_call_counters::KIND_READ_VERSION),
|
||||
metadata_calls * 2,
|
||||
"each range must select the authoritative winner across all pools"
|
||||
metadata_calls,
|
||||
"SelectObjectContent must consume the prepared winner without a second fanout"
|
||||
);
|
||||
let mut restored = Vec::new();
|
||||
reader
|
||||
@@ -12829,18 +12583,6 @@ mod tests {
|
||||
.await
|
||||
.expect("snapshot body should stream");
|
||||
assert_eq!(restored, payload);
|
||||
|
||||
// A later authoritative winner can be in a different pool. Reopening
|
||||
// only the initially selected pool would silently keep stale metadata.
|
||||
let mut replacement = PutObjReader::from_vec(b"new-pool-winner".to_vec());
|
||||
store.pools[0]
|
||||
.put_object(bucket, object, &mut replacement, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("normal overwrite in another pool");
|
||||
assert!(matches!(
|
||||
snapshot.open_reader(None).await,
|
||||
Err(SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -13157,7 +12899,7 @@ mod tests {
|
||||
async fn select_snapshot_locks_the_hashed_set_in_every_pool() {
|
||||
let (_first_dirs, first_set) = make_local_set_disks(4, 2).await;
|
||||
let (_second_dirs, second_set) = make_local_set_disks(4, 2).await;
|
||||
let store = Arc::new(new_prepared_reader_test_store(&[first_set, second_set]).await);
|
||||
let store = new_prepared_reader_test_store(&[first_set, second_set]).await;
|
||||
let bucket = "select-snapshot-all-pools";
|
||||
let object = "object.bin";
|
||||
let payload = b"second-pool-snapshot".to_vec();
|
||||
@@ -13181,21 +12923,10 @@ mod tests {
|
||||
.prepare_select_object_snapshot(bucket, object, &HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("snapshot should resolve the second-pool object");
|
||||
drop(acquire_pool_writer(&store, 0, bucket, object).await);
|
||||
drop(acquire_pool_writer(&store, 1, bucket, object).await);
|
||||
let snapshot = Arc::new(snapshot);
|
||||
let barrier = SelectRangeBarrierGuard::install(bucket, object);
|
||||
let opening = tokio::spawn({
|
||||
let snapshot = Arc::clone(&snapshot);
|
||||
async move { snapshot.open_reader(None).await }
|
||||
});
|
||||
barrier.wait().await;
|
||||
assert_pool_writer_is_blocked(&store, 0, bucket, object).await;
|
||||
assert_pool_writer_is_blocked(&store, 1, bucket, object).await;
|
||||
drop(barrier);
|
||||
let mut reader = opening.await.expect("range task").expect("range opens");
|
||||
drop(acquire_pool_writer(&store, 0, bucket, object).await);
|
||||
drop(acquire_pool_writer(&store, 1, bucket, object).await);
|
||||
|
||||
let mut reader = snapshot.open_reader(None).await.expect("snapshot reader should open");
|
||||
let mut restored = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
|
||||
@@ -20,230 +20,14 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use storage_api::contract_compat::ECStore;
|
||||
use storage_api::metadata_lock::{
|
||||
BucketOperations, CompletePart, DeleteBucketOptions, Error, HashReader, MakeBucketOptions, MultipartOperations,
|
||||
NamespaceLocking, ObjectIO, ObjectOperations, ObjectOptions, PutObjReader, PutObjectCommitBarrier, PutObjectCommitPause,
|
||||
init_bucket_metadata_sys, isolated_store_over_temp_disks,
|
||||
BucketOperations, CompletePart, Error, MakeBucketOptions, MultipartOperations, NamespaceLocking, ObjectIO, ObjectOperations,
|
||||
ObjectOptions, PutObjReader, PutObjectCommitBarrier, PutObjectCommitPause, init_bucket_metadata_sys,
|
||||
isolated_store_over_temp_disks,
|
||||
};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct ObservedPutBody {
|
||||
inner: tokio::io::DuplexStream,
|
||||
polled: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for ObservedPutBody {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
self.polled.notify_one();
|
||||
std::pin::Pin::new(&mut self.inner).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn destructive_put_takes_bucket_locks_only_after_its_body_is_staged() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
for (object, version_id) in [("object", None), ("directory/", Some(Uuid::nil().to_string()))] {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "deferred-put-locks";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket");
|
||||
let encoded_object = if object.ends_with('/') { "directory__XLDIR__" } else { object };
|
||||
let barrier = PutObjectCommitBarrier::install(bucket, encoded_object, PutObjectCommitPause::BeforeQuotaRename);
|
||||
let (mut sender, inner) = tokio::io::duplex(16);
|
||||
let polled = Arc::new(tokio::sync::Notify::new());
|
||||
let body = ObservedPutBody {
|
||||
inner,
|
||||
polled: polled.clone(),
|
||||
};
|
||||
let put_store = store.clone();
|
||||
let put = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::new(HashReader::from_stream(body, 4, 4, None, None, false).expect("body"));
|
||||
put_store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
timeout(Duration::from_secs(5), polled.notified())
|
||||
.await
|
||||
.expect("PUT must be waiting for client data");
|
||||
let lock = store
|
||||
.new_ns_lock(".rustfs.sys", &format!("bucket-targets/{bucket}/transaction.lock"))
|
||||
.await
|
||||
.expect("metadata lock");
|
||||
drop(
|
||||
timeout(Duration::from_secs(2), lock.get_write_lock(Duration::from_secs(5)))
|
||||
.await
|
||||
.expect("body must not block metadata writers")
|
||||
.expect("metadata write guard"),
|
||||
);
|
||||
drop(
|
||||
timeout(Duration::from_secs(2), store.lock_bucket_for_recursive_delete(bucket))
|
||||
.await
|
||||
.expect("body must not retain lifecycle guard")
|
||||
.expect("lifecycle writer"),
|
||||
);
|
||||
sender.write_all(b"body").await.expect("finish body");
|
||||
sender.shutdown().await.expect("EOF");
|
||||
timeout(Duration::from_secs(5), barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("staged PUT reaches commit");
|
||||
let writer = lock.get_write_lock(Duration::from_secs(5));
|
||||
tokio::pin!(writer);
|
||||
assert!(futures::poll!(&mut writer).is_pending(), "commit must fence metadata updates");
|
||||
let lifecycle = store.lock_bucket_for_recursive_delete(bucket);
|
||||
tokio::pin!(lifecycle);
|
||||
assert!(futures::poll!(&mut lifecycle).is_pending(), "commit must fence bucket deletion");
|
||||
barrier.release();
|
||||
let release_metadata = async {
|
||||
drop(writer.await.expect("metadata writer"));
|
||||
};
|
||||
let release_lifecycle = async {
|
||||
drop(lifecycle.await.expect("lifecycle writer"));
|
||||
};
|
||||
timeout(Duration::from_secs(5), async {
|
||||
let (put, (), ()) = tokio::join!(put, release_metadata, release_lifecycle);
|
||||
put.expect("owner task").expect("commit");
|
||||
})
|
||||
.await
|
||||
.expect("PUT and queued writers finish");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn staged_put_cannot_publish_into_a_recreated_bucket() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "deferred-put-generation";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket");
|
||||
let (mut sender, inner) = tokio::io::duplex(16);
|
||||
let polled = Arc::new(tokio::sync::Notify::new());
|
||||
let body = ObservedPutBody {
|
||||
inner,
|
||||
polled: polled.clone(),
|
||||
};
|
||||
let put_store = store.clone();
|
||||
let put = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::new(HashReader::from_stream(body, 4, 4, None, None, false).expect("body"));
|
||||
put_store
|
||||
.put_object(bucket, "object", &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
timeout(Duration::from_secs(5), polled.notified()).await.expect("body paused");
|
||||
store
|
||||
.delete_bucket(
|
||||
bucket,
|
||||
&DeleteBucketOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("remove old bucket while PUT stalls");
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate bucket");
|
||||
sender.write_all(b"body").await.expect("finish body");
|
||||
sender.shutdown().await.expect("EOF");
|
||||
let error = timeout(Duration::from_secs(5), put)
|
||||
.await
|
||||
.expect("commit gate")
|
||||
.expect("owner")
|
||||
.expect_err("old request generation must not publish");
|
||||
assert!(
|
||||
error.to_string().contains("generation") || matches!(error, Error::BucketNotFound(..)),
|
||||
"{error:?}"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_object_info(bucket, "object", &ObjectOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn staged_put_rechecks_current_object_lock_at_commit() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "deferred-put-retention";
|
||||
store
|
||||
.make_bucket(
|
||||
bucket,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("locked bucket");
|
||||
let mut old = PutObjReader::from_vec(b"old".to_vec());
|
||||
store
|
||||
.put_object(bucket, "object", &mut old, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("unlocked predecessor");
|
||||
let (mut sender, inner) = tokio::io::duplex(16);
|
||||
let polled = Arc::new(tokio::sync::Notify::new());
|
||||
let body = ObservedPutBody {
|
||||
inner,
|
||||
polled: polled.clone(),
|
||||
};
|
||||
let put_store = store.clone();
|
||||
let put = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::new(HashReader::from_stream(body, 4, 4, None, None, false).expect("body"));
|
||||
put_store
|
||||
.put_object(bucket, "object", &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
timeout(Duration::from_secs(5), polled.notified()).await.expect("body paused");
|
||||
let mut protected = PutObjReader::from_vec(b"protected".to_vec());
|
||||
store
|
||||
.put_object(
|
||||
bucket,
|
||||
"object",
|
||||
&mut protected,
|
||||
&ObjectOptions {
|
||||
user_defined: std::collections::HashMap::from([("x-amz-object-lock-legal-hold".to_string(), "ON".to_string())]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("install a protected predecessor while the client stalls");
|
||||
sender.write_all(b"body").await.expect("finish stale body");
|
||||
sender.shutdown().await.expect("EOF");
|
||||
let error = timeout(Duration::from_secs(5), put)
|
||||
.await
|
||||
.expect("commit gate")
|
||||
.expect("task")
|
||||
.expect_err("pre-body state cannot admit an overwrite of the newly protected version");
|
||||
assert!(matches!(error, Error::PrefixAccessDenied(..)), "{error:?}");
|
||||
let info = store
|
||||
.get_object_info(bucket, "object", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("protected object remains");
|
||||
assert_eq!(info.size, 9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replica_put_completes_while_metadata_writer_waits_for_its_snapshot() {
|
||||
replica_write_with_waiting_metadata_writer(false).await;
|
||||
|
||||
@@ -9,14 +9,12 @@ use rustfs_storage_api as storage_contracts;
|
||||
#[cfg(feature = "test-util")]
|
||||
pub(crate) mod metadata_lock {
|
||||
pub(crate) use super::storage_contracts::{
|
||||
BucketOperations, CompletePart, DeleteBucketOptions, MakeBucketOptions, MultipartOperations, NamespaceLocking, ObjectIO,
|
||||
ObjectOperations,
|
||||
BucketOperations, CompletePart, MakeBucketOptions, MultipartOperations, NamespaceLocking, ObjectIO, ObjectOperations,
|
||||
};
|
||||
pub(crate) use super::{Error, ObjectOptions, PutObjReader};
|
||||
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::{
|
||||
init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks,
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::rio::HashReader;
|
||||
pub(crate) use rustfs_ecstore::api::set_disk::test_util::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
}
|
||||
|
||||
|
||||
@@ -3930,34 +3930,6 @@ mod test {
|
||||
assert!(versions_with_free.versions.iter().all(|version| version.num_versions == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_rejects_mod_times_that_cannot_round_trip() {
|
||||
for nanos in [0, i128::from(i64::MAX) + 1, i128::from(i64::MIN) - 1, 1_i128 << 64] {
|
||||
for deleted in [false, true] {
|
||||
let mut meta = FileMeta::new();
|
||||
let mut info = crate::fileinfo::FileInfo::new("object", 2, 1);
|
||||
info.deleted = deleted;
|
||||
info.mod_time = Some(OffsetDateTime::from_unix_timestamp_nanos(nanos).expect("time within supported date range"));
|
||||
assert!(
|
||||
meta.add_version(info).is_err(),
|
||||
"unrepresentable time {nanos} must be rejected before publication"
|
||||
);
|
||||
}
|
||||
}
|
||||
for nanos in [-1, 1, i64::MIN, i64::MAX] {
|
||||
let mut meta = FileMeta::new();
|
||||
let mut info = crate::fileinfo::FileInfo::new("object", 2, 1);
|
||||
let time = OffsetDateTime::from_unix_timestamp_nanos(i128::from(nanos)).expect("valid codec time");
|
||||
info.mod_time = Some(time);
|
||||
meta.add_version(info).expect("representable times remain supported");
|
||||
let reopened = FileMeta::load(&meta.marshal_msg().expect("serialize version")).expect("reopen version");
|
||||
let versions = reopened
|
||||
.get_file_info_versions("bucket", "object", false)
|
||||
.expect("read version history");
|
||||
assert_eq!(versions.versions[0].mod_time, Some(time));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_integrity_validation() {
|
||||
// Test data integrity checks
|
||||
|
||||
@@ -858,24 +858,13 @@ impl FileMetaVersion {
|
||||
VersionType::Object => self
|
||||
.object
|
||||
.as_ref()
|
||||
.map(|v| {
|
||||
v.erasure_algorithm.valid()
|
||||
&& v.bitrot_checksum_algo.valid()
|
||||
&& v.mod_time.is_some_and(|time| {
|
||||
let nanos = time.unix_timestamp_nanos();
|
||||
nanos != 0 && i64::try_from(nanos).is_ok()
|
||||
})
|
||||
})
|
||||
.map(|v| v.erasure_algorithm.valid() && v.bitrot_checksum_algo.valid() && v.mod_time.is_some())
|
||||
.unwrap_or_default(),
|
||||
VersionType::Legacy => self.legacy_object.as_ref().map(MetaObjectV1::valid).unwrap_or_default(),
|
||||
VersionType::Delete => self
|
||||
.delete_marker
|
||||
.as_ref()
|
||||
.map(|v| {
|
||||
v.mod_time.is_some_and(|time| {
|
||||
time > OffsetDateTime::UNIX_EPOCH && i64::try_from(time.unix_timestamp_nanos()).is_ok()
|
||||
})
|
||||
})
|
||||
.map(|v| v.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH) > OffsetDateTime::UNIX_EPOCH)
|
||||
.unwrap_or_default(),
|
||||
_ => false,
|
||||
}
|
||||
|
||||
@@ -77,9 +77,6 @@ pub enum Error {
|
||||
#[error("Replacement target is not ready: {0}")]
|
||||
ReplacementTargetNotReady(String),
|
||||
|
||||
#[error("Replacement bucket namespace changed; a fresh identity-bound pass is required")]
|
||||
ReplacementNamespaceChanged,
|
||||
|
||||
#[error("replacement recovery retry budget exhausted")]
|
||||
ReplacementRetryBudgetExhausted,
|
||||
|
||||
@@ -135,10 +132,7 @@ impl Error {
|
||||
/// Callers must cut their own concurrency instead of retrying at full speed.
|
||||
pub(crate) fn is_slow_down(&self) -> bool {
|
||||
match self {
|
||||
Error::Storage(error) => matches!(
|
||||
error,
|
||||
EcstoreError::SlowDown | EcstoreError::TooManyOpenFiles | EcstoreError::DeleteRecoveryWorkLimit
|
||||
),
|
||||
Error::Storage(error) => matches!(error, EcstoreError::SlowDown | EcstoreError::TooManyOpenFiles),
|
||||
Error::Disk(error) => matches!(error, DiskError::TooManyOpenFiles),
|
||||
Error::TransientSkip { message } | Error::Other(message) | Error::TaskExecutionFailed { message } => {
|
||||
message.contains("Please reduce your request rate")
|
||||
@@ -235,10 +229,9 @@ pub(crate) fn classify_heal_error(error: &Error) -> HealErrorClass {
|
||||
Error::DanglingDeleteDeferred { retry_not_before } => HealErrorClass::DanglingGrace {
|
||||
retry_not_before: Some(*retry_not_before),
|
||||
},
|
||||
Error::ReplacementTargetNotReady(_)
|
||||
| Error::ReplacementNamespaceChanged
|
||||
| Error::TransientSkip { .. }
|
||||
| Error::BucketDiscoveryIncomplete => HealErrorClass::Transient,
|
||||
Error::ReplacementTargetNotReady(_) | Error::TransientSkip { .. } | Error::BucketDiscoveryIncomplete => {
|
||||
HealErrorClass::Transient
|
||||
}
|
||||
Error::LegacyMrfUnverified { .. } => HealErrorClass::Permanent,
|
||||
Error::Storage(error) => classify_storage_error(error),
|
||||
Error::Disk(error) => classify_disk_error(error),
|
||||
@@ -292,7 +285,6 @@ pub(crate) fn classify_storage_error(error: &EcstoreError) -> HealErrorClass {
|
||||
| EcstoreError::FaultyDisk
|
||||
| EcstoreError::FaultyRemoteDisk
|
||||
| EcstoreError::SlowDown
|
||||
| EcstoreError::DeleteRecoveryWorkLimit
|
||||
| EcstoreError::OperationCanceled
|
||||
| EcstoreError::RemoteClientUnavailable(_) => HealErrorClass::Transient,
|
||||
_ if error.is_quorum_error() => HealErrorClass::Transient,
|
||||
|
||||
@@ -468,16 +468,7 @@ impl ErasureSetHealer {
|
||||
let task_id = self.get_or_create_task_id(set_disk_id).await?;
|
||||
|
||||
// 2. initialize or resume resume state
|
||||
let (resume_manager, mut checkpoint_manager) = self.initialize_resume_state(&task_id, set_disk_id, buckets).await?;
|
||||
let replacement_buckets = if self.replacement_task_id.is_some() {
|
||||
let current = resume_manager.prepare_replacement_bucket_pass(self.storage.as_ref()).await?;
|
||||
// Preparation may have durably rewound the companion checkpoint.
|
||||
checkpoint_manager = CheckpointManager::load_from_disk(self.disk.clone(), &task_id).await?;
|
||||
Some(current)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let buckets = replacement_buckets.as_deref().unwrap_or(buckets);
|
||||
let (resume_manager, checkpoint_manager) = self.initialize_resume_state(&task_id, set_disk_id, buckets).await?;
|
||||
|
||||
let admin_buckets = if self.admin_task.is_some() {
|
||||
Some(
|
||||
@@ -498,12 +489,6 @@ impl ErasureSetHealer {
|
||||
.execute_heal_with_resume(buckets, set_disk_id, &resume_manager, &checkpoint_manager)
|
||||
.await;
|
||||
|
||||
// A cancelled or timed-out pass keeps its control classification: the
|
||||
// namespace check runs only for completed or storage-failed passes so a
|
||||
// concurrent bucket deletion cannot reclassify a stop as a failure.
|
||||
if self.replacement_task_id.is_some() && !matches!(result, Err(Error::TaskCancelled | Error::TaskTimeout)) {
|
||||
resume_manager.verify_replacement_bucket_pass(self.storage.as_ref()).await?;
|
||||
}
|
||||
result?;
|
||||
self.verify_replacement_identity_fence("completion").await?;
|
||||
|
||||
@@ -982,16 +967,9 @@ impl ErasureSetHealer {
|
||||
// Recheck even resumed buckets: their user-object cursor does not
|
||||
// prove that the replacement holds the internal bucket records.
|
||||
for bucket in buckets {
|
||||
if self.replacement_task_id.is_some() {
|
||||
let expected = resume_manager.replacement_bucket_incarnation(bucket).await?;
|
||||
self.storage
|
||||
.heal_replacement_bucket_metadata_at_incarnation(bucket, expected, &self.heal_opts, targets)
|
||||
.await?;
|
||||
} else {
|
||||
self.storage
|
||||
.heal_replacement_bucket_metadata(bucket, &self.heal_opts, targets)
|
||||
.await?;
|
||||
}
|
||||
self.storage
|
||||
.heal_replacement_bucket_metadata(bucket, &self.heal_opts, targets)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
self.heal_replacement_pool_metadata(
|
||||
@@ -1041,7 +1019,6 @@ impl ErasureSetHealer {
|
||||
// never hot-retried in place here.
|
||||
if failed_objects > 0 || skipped_objects > 0 || failed_buckets > 0 {
|
||||
if self.replacement_task_id.is_some() {
|
||||
resume_manager.verify_replacement_bucket_pass(self.storage.as_ref()).await?;
|
||||
let state = resume_manager.get_state().await;
|
||||
let (grace_objects, retry_not_before, counter_unknown) = checkpoint_manager.dangling_delete_grace().await;
|
||||
if state.can_retry()
|
||||
@@ -1475,11 +1452,7 @@ impl ErasureSetHealer {
|
||||
"Erasure set bucket started"
|
||||
);
|
||||
|
||||
let incarnation = if self.replacement_task_id.is_some() {
|
||||
Some(resume_manager.replacement_bucket_incarnation(bucket).await?)
|
||||
} else {
|
||||
self.admin_bucket_incarnation(checkpoint_manager, bucket).await?
|
||||
};
|
||||
let incarnation = self.admin_bucket_incarnation(checkpoint_manager, bucket).await?;
|
||||
let unresolved_delete_recovery = self.heal_admin_bucket_delete_recovery(bucket, incarnation).await?;
|
||||
if unresolved_delete_recovery && !self.heal_opts.dry_run {
|
||||
resume_manager.set_delete_recovery_rescan_pending(true).await?;
|
||||
@@ -1771,20 +1744,15 @@ impl ErasureSetHealer {
|
||||
(0, Err(Error::TaskCancelled))
|
||||
} else {
|
||||
let result = if replacement_object {
|
||||
match incarnation {
|
||||
Some(expected) => storage
|
||||
.heal_replacement_object_at_incarnation(
|
||||
&bucket_name,
|
||||
&object_name,
|
||||
version_id.as_deref(),
|
||||
expected,
|
||||
&heal_opts,
|
||||
&cancel_token,
|
||||
)
|
||||
.await
|
||||
.map(|result| (result.item, result.error)),
|
||||
None => Err(Error::ReplacementNamespaceChanged),
|
||||
}
|
||||
storage
|
||||
.heal_object_with_task_cancel(
|
||||
&bucket_name,
|
||||
&object_name,
|
||||
version_id.as_deref(),
|
||||
&heal_opts,
|
||||
&cancel_token,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
storage
|
||||
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
|
||||
@@ -2670,7 +2638,6 @@ mod resume_loop_tests {
|
||||
struct FakeStorage {
|
||||
admin_disk: Mutex<Option<DiskStore>>,
|
||||
bucket_discovery_incomplete: AtomicBool,
|
||||
bucket_discovery_error: Mutex<Option<EcstoreError>>,
|
||||
bucket_discovery_scopes: Mutex<Vec<Option<crate::heal::storage_api::storage::DiskSetSelector>>>,
|
||||
bucket_pages: Mutex<HashMap<String, Page>>,
|
||||
incarnations: Mutex<HashMap<String, uuid::Uuid>>,
|
||||
@@ -2762,9 +2729,6 @@ mod resume_loop_tests {
|
||||
if self.bucket_discovery_incomplete.load(Ordering::SeqCst) {
|
||||
return Err(Error::BucketDiscoveryIncomplete);
|
||||
}
|
||||
if let Some(error) = self.bucket_discovery_error.lock().expect("discovery failure").clone() {
|
||||
return Err(Error::Storage(error));
|
||||
}
|
||||
self.list_buckets().await
|
||||
}
|
||||
|
||||
@@ -2941,16 +2905,6 @@ mod resume_loop_tests {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn heal_replacement_bucket_metadata_at_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
expected: uuid::Uuid,
|
||||
opts: &HealOpts,
|
||||
targets: &[String],
|
||||
) -> Result<()> {
|
||||
self.validate_bucket_incarnation(bucket, Some(expected)).await?;
|
||||
self.heal_replacement_bucket_metadata(bucket, opts, targets).await
|
||||
}
|
||||
async fn replacement_targets_have_version(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
@@ -3067,16 +3021,6 @@ mod resume_loop_tests {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let disk = make_disk(&temp).await;
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
if !target_endpoints.is_empty() {
|
||||
storage.bucket_pages.lock().expect("replacement inventory").insert(
|
||||
"b".to_string(),
|
||||
Page {
|
||||
items: Vec::new(),
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
let task_id = ResumeUtils::generate_task_id();
|
||||
let healer = ErasureSetHealer::new(
|
||||
storage.clone(),
|
||||
@@ -3096,17 +3040,7 @@ mod resume_loop_tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if !healer.target_endpoints.is_empty() {
|
||||
resume
|
||||
.prepare_replacement_bucket_pass(storage.as_ref())
|
||||
.await
|
||||
.expect("replacement fixture identity admission");
|
||||
}
|
||||
let checkpoint = if CheckpointManager::has_checkpoint(&disk, &task_id).await {
|
||||
CheckpointManager::load_from_disk(disk, &task_id).await.unwrap()
|
||||
} else {
|
||||
CheckpointManager::new(disk, task_id.clone()).await.unwrap()
|
||||
};
|
||||
let checkpoint = CheckpointManager::new(disk, task_id.clone()).await.unwrap();
|
||||
Env {
|
||||
healer,
|
||||
storage,
|
||||
@@ -3146,60 +3080,6 @@ mod resume_loop_tests {
|
||||
(processed, successful, failed, skipped, result.map(|_| ()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_bucket_pass_rejects_incomplete_inventory_without_reusing_or_clearing_progress() {
|
||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
env.resume
|
||||
.set_resume_cursor(Some("held-cursor".to_string()))
|
||||
.await
|
||||
.expect("cursor before discovery failure");
|
||||
env.checkpoint
|
||||
.add_processed_object("held-key".to_string())
|
||||
.await
|
||||
.expect("dedup before discovery failure");
|
||||
// Model a page whose dedup identities must survive until cursor
|
||||
// publication. complete_page deliberately prunes that identity set.
|
||||
env.checkpoint
|
||||
.advance_page(0, 7)
|
||||
.await
|
||||
.expect("persist the retained page identities");
|
||||
let before = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||
.await
|
||||
.expect("durable checkpoint before discovery failure")
|
||||
.get_checkpoint()
|
||||
.await;
|
||||
assert_eq!(before.current_object_index, 7);
|
||||
assert!(before.processed_objects.contains("held-key"));
|
||||
let state = env.resume.get_state().await;
|
||||
env.storage.bucket_discovery_incomplete.store(true, Ordering::SeqCst);
|
||||
assert!(matches!(
|
||||
env.resume.prepare_replacement_bucket_pass(env.storage.as_ref()).await,
|
||||
Err(Error::BucketDiscoveryIncomplete)
|
||||
));
|
||||
assert_eq!(env.resume.get_state().await, state, "partial discovery must never publish a fresh plan");
|
||||
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||
.await
|
||||
.expect("durable checkpoint")
|
||||
.get_checkpoint()
|
||||
.await;
|
||||
assert_eq!(checkpoint.current_object_index, 7);
|
||||
assert!(checkpoint.processed_objects.contains("held-key"));
|
||||
env.storage.bucket_discovery_incomplete.store(false, Ordering::SeqCst);
|
||||
*env.storage.bucket_discovery_error.lock().expect("inject quorum failure") =
|
||||
Some(EcstoreError::InsufficientReadQuorum("bucket".to_string(), "inventory".to_string()));
|
||||
assert!(matches!(
|
||||
env.resume.prepare_replacement_bucket_pass(env.storage.as_ref()).await,
|
||||
Err(Error::Storage(EcstoreError::InsufficientReadQuorum(..)))
|
||||
));
|
||||
assert_eq!(env.resume.get_state().await, state, "quorum failure is not an empty namespace");
|
||||
*env.storage.bucket_discovery_error.lock().expect("restore inventory") = None;
|
||||
env.resume
|
||||
.prepare_replacement_bucket_pass(env.storage.as_ref())
|
||||
.await
|
||||
.expect("unchanged complete inventory resumes existing pass");
|
||||
assert_eq!(env.resume.get_state().await.resume_cursor.as_deref(), Some("held-cursor"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_bucket_no_panic() {
|
||||
let env = make_env().await;
|
||||
@@ -3602,85 +3482,6 @@ mod resume_loop_tests {
|
||||
drop(checkpoint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_cancellation_keeps_its_classification_when_the_namespace_changes() {
|
||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let replacement_task_id = ResumeUtils::generate_task_id();
|
||||
ResumeManager::new_replacement_intent(
|
||||
env.healer.disk.clone(),
|
||||
replacement_task_id.clone(),
|
||||
"pool_0_set_0".to_string(),
|
||||
vec!["b".to_string()],
|
||||
vec!["replacement-a".to_string()],
|
||||
vec![crate::heal::resume::ReplacementTargetIdentity {
|
||||
endpoint: "replacement-a".to_string(),
|
||||
canonical_path: "/mnt/replacement-a".to_string(),
|
||||
physical_device_ids: vec!["device-a".to_string()],
|
||||
filesystem_identity: "1:2:3".to_string(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("replacement intent should persist");
|
||||
env.storage.set_page(
|
||||
None,
|
||||
Page {
|
||||
items: vec![item("retry", None, false)],
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
env.storage.set_outcome("retry", None, HealOutcome::Transient);
|
||||
let healer = ErasureSetHealer::new(
|
||||
env.storage.clone(),
|
||||
Arc::new(RwLock::new(HealProgress::new())),
|
||||
CancellationToken::new(),
|
||||
env.healer.disk.clone(),
|
||||
HealOpts::default(),
|
||||
HealRequestSource::AutoHeal,
|
||||
)
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
|
||||
let cancel = healer.cancel_token.clone();
|
||||
let observe = async {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let notified = env.storage.heal_started.notified();
|
||||
if !env.storage.calls().is_empty() {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the replacement object repair must start");
|
||||
// The only bucket is recreated while the operator cancels the pass.
|
||||
env.storage
|
||||
.incarnations
|
||||
.lock()
|
||||
.expect("incarnations")
|
||||
.insert("b".to_string(), uuid::Uuid::from_u128(7));
|
||||
cancel.cancel();
|
||||
};
|
||||
|
||||
let buckets = ["b".to_string()];
|
||||
let (result, ()) = tokio::join!(healer.heal_erasure_set(&buckets, "pool_0_set_0"), observe);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::TaskCancelled)),
|
||||
"a cancelled pass keeps its control classification: {result:?}"
|
||||
);
|
||||
let manager = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
|
||||
.await
|
||||
.expect("cancelled replacement intent must remain");
|
||||
assert!(
|
||||
matches!(
|
||||
manager.verify_replacement_bucket_pass(env.storage.as_ref()).await,
|
||||
Err(Error::ReplacementNamespaceChanged)
|
||||
),
|
||||
"the fixture must have changed the recorded bucket identity"
|
||||
);
|
||||
assert!(!manager.get_state().await.completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_pool_metadata_readback_failure_schedules_retry() {
|
||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
@@ -4254,7 +4055,7 @@ mod resume_loop_tests {
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
|
||||
],
|
||||
async {
|
||||
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer = env
|
||||
.healer
|
||||
@@ -4309,7 +4110,7 @@ mod resume_loop_tests {
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
|
||||
],
|
||||
async {
|
||||
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer.heal_opts.no_lock = true;
|
||||
env.healer = env
|
||||
@@ -4366,7 +4167,7 @@ mod resume_loop_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_replacement_retries_a_transient_object_before_rewinding_the_scan() {
|
||||
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer = env
|
||||
.healer
|
||||
@@ -4398,7 +4199,7 @@ mod resume_loop_tests {
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("1")),
|
||||
],
|
||||
async {
|
||||
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer = env
|
||||
.healer
|
||||
@@ -4430,7 +4231,7 @@ mod resume_loop_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_replacement_cancellation_interrupts_object_retry() {
|
||||
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer = env
|
||||
.healer
|
||||
@@ -4446,18 +4247,14 @@ mod resume_loop_tests {
|
||||
env.storage.set_outcome("retry", None, HealOutcome::Transient);
|
||||
let cancel = env.healer.cancel_token.clone();
|
||||
let observe = async {
|
||||
let started = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let notified = env.storage.heal_started.notified();
|
||||
if !env.storage.calls().is_empty() {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
loop {
|
||||
let notified = env.storage.heal_started.notified();
|
||||
if !env.storage.calls().is_empty() {
|
||||
break;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
notified.await;
|
||||
}
|
||||
cancel.cancel();
|
||||
started.expect("replacement object retry must start");
|
||||
};
|
||||
|
||||
let ((_, successful, _, _, result), ()) = tokio::join!(run(&env), observe);
|
||||
@@ -4475,7 +4272,7 @@ mod resume_loop_tests {
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
|
||||
],
|
||||
async {
|
||||
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
let execution = crate::heal::storage::ReplacementExecution::for_test(Vec::new(), Vec::new());
|
||||
env.healer = env
|
||||
|
||||
@@ -51,25 +51,6 @@ async fn replacement_env() -> Env {
|
||||
)
|
||||
.await
|
||||
.expect("replacement intent should persist before scanning");
|
||||
env.resume
|
||||
.prepare_replacement_bucket_pass(env.storage.as_ref())
|
||||
.await
|
||||
.expect("the new replacement generation must admit its bucket identities before direct execution");
|
||||
// Admission durably resets the companion checkpoint. Reload its current
|
||||
// revision instead of retaining the previous generation's CAS instance.
|
||||
env.checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||
.await
|
||||
.expect("reload the admitted replacement pass checkpoint");
|
||||
assert_eq!(
|
||||
env.resume
|
||||
.replacement_bucket_incarnation("b")
|
||||
.await
|
||||
.expect("the fixture bucket must belong to the replacement pass"),
|
||||
env.storage
|
||||
.admit_bucket_incarnation("b")
|
||||
.await
|
||||
.expect("fixture bucket identity")
|
||||
);
|
||||
env.healer = ErasureSetHealer::new(
|
||||
env.storage.clone(),
|
||||
Arc::new(RwLock::new(HealProgress::new())),
|
||||
|
||||
@@ -881,13 +881,6 @@ async fn auto_format_fixture(
|
||||
// Preserve the constructor's explicit open/closed state before any fixture await.
|
||||
let node_limit = node.as_ref().map(|node| node.snapshot().limit);
|
||||
let fixture = resume_admission_fixture(node.clone()).await;
|
||||
// The endpoint monitor independently probes format.json after five seconds.
|
||||
// Drain it before aging the cache so the path-only pause observes this scanner.
|
||||
for pool in &fixture._store.ecstore.pools {
|
||||
tokio::time::timeout(Duration::from_secs(30), pool.stop_endpoint_monitor_for_test())
|
||||
.await
|
||||
.expect("endpoint reconnection must stop before the scanner owns format probes");
|
||||
}
|
||||
let disks = crate::heal::local_disk_map_read()
|
||||
.await
|
||||
.values()
|
||||
|
||||
@@ -27,7 +27,6 @@ use super::{
|
||||
BUCKET_META_PREFIX, DiskError, DiskStore, HealDiskExt as _, RUSTFS_META_BUCKET, storage_api::owner::EcstoreDiskBytes,
|
||||
};
|
||||
|
||||
mod bucket_pass;
|
||||
mod checkpoint;
|
||||
mod gc;
|
||||
mod handoff;
|
||||
@@ -70,9 +69,7 @@ const RESUME_PROGRESS_FILE: &str = "ahm_progress.json";
|
||||
pub(super) const RESUME_CHECKPOINT_FILE: &str = "ahm_checkpoint.json";
|
||||
pub(super) const RESUME_CHECKPOINT_BLOCKED_FILE: &str = "ahm_checkpoint.blocked";
|
||||
const REPLACEMENT_COMPLETION_PROOF_FILE: &str = "ahm_replacement_completion_proof.json";
|
||||
// Owned by ecstore: bucket deletion must keep this directory when a user
|
||||
// bucket carries the same name, so both sides read one definition.
|
||||
const REPLACEMENT_RECOVERY_DIR: &str = super::storage_api::owner::ECSTORE_HEAL_REPLACEMENT_RECOVERY_DIR;
|
||||
const REPLACEMENT_RECOVERY_DIR: &str = "ahm-replacement";
|
||||
const REPLACEMENT_INTENT_SEAL_FILE: &str = "ahm_replacement_intent_seal";
|
||||
const LEGACY_REPLACEMENT_RECOVERY_MARKER_FILE: &str = "ahm_replacement_recovery.json";
|
||||
const REPLACEMENT_RECOVERY_CONFLICT_PREFIX: &str = "replacement recovery conflict:";
|
||||
@@ -348,22 +345,6 @@ pub struct ResumeState {
|
||||
/// input after restart.
|
||||
#[serde(default)]
|
||||
pub replacement_buckets: Vec<String>,
|
||||
/// Current namespace pass, separate from the immutable generation plan.
|
||||
/// Legacy snapshots have no binding and must discard every old cursor.
|
||||
#[serde(default)]
|
||||
pub replacement_bucket_incarnations: Option<std::collections::BTreeMap<String, Uuid>>,
|
||||
/// Last proven retired identity per name, retained for returning stale volumes.
|
||||
#[serde(default)]
|
||||
pub replacement_retired_bucket_incarnations: std::collections::BTreeMap<String, Uuid>,
|
||||
/// Positional bucket list of the current pass; the companion checkpoint's
|
||||
/// bucket index refers to it. Buckets created mid-pass are appended so the
|
||||
/// index keeps its meaning. Empty in snapshots written before additions
|
||||
/// were admitted, where the order is the sorted identity map.
|
||||
#[serde(default)]
|
||||
pub replacement_bucket_pass_order: Vec<String>,
|
||||
/// Reapply the checkpoint rewind after any interruption between the two writes.
|
||||
#[serde(default)]
|
||||
pub replacement_bucket_pass_reset_pending: bool,
|
||||
/// A task-scoped generation assigned before formatting an automatic
|
||||
/// replacement. A new unformatted replacement receives a new generation
|
||||
/// and therefore cannot reuse an older disk's cursor or checkpoint.
|
||||
@@ -473,10 +454,6 @@ impl ResumeState {
|
||||
replacement_targets: Vec::new(),
|
||||
replacement_target_identities: Vec::new(),
|
||||
replacement_buckets: Vec::new(),
|
||||
replacement_bucket_incarnations: None,
|
||||
replacement_retired_bucket_incarnations: std::collections::BTreeMap::new(),
|
||||
replacement_bucket_pass_order: Vec::new(),
|
||||
replacement_bucket_pass_reset_pending: false,
|
||||
replacement_generation: None,
|
||||
replacement_phase: ReplacementPhase::None,
|
||||
replacement_execution_protocol: 0,
|
||||
@@ -596,18 +573,6 @@ impl ResumeState {
|
||||
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
}
|
||||
|
||||
/// Buckets of the current replacement pass in checkpoint order.
|
||||
pub fn replacement_bucket_pass_buckets(&self) -> Vec<String> {
|
||||
if self.replacement_bucket_pass_order.is_empty() {
|
||||
self.replacement_bucket_incarnations
|
||||
.as_ref()
|
||||
.map(|identities| identities.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
self.replacement_bucket_pass_order.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete_bucket(&mut self, bucket: &str) {
|
||||
if !self.completed_buckets.contains(&bucket.to_string()) {
|
||||
self.completed_buckets.push(bucket.to_string());
|
||||
@@ -1020,29 +985,7 @@ impl ResumeManager {
|
||||
return Err(replacement_recovery_conflict("invalid retired legacy generation"));
|
||||
}
|
||||
}
|
||||
let valid_execution_protocol = match state.replacement_execution_protocol {
|
||||
1 => state.replacement_bucket_incarnations.is_none(),
|
||||
2 => state
|
||||
.replacement_bucket_incarnations
|
||||
.as_ref()
|
||||
.is_some_and(|identities| identities.values().all(|incarnation| !incarnation.is_nil())),
|
||||
_ => false,
|
||||
};
|
||||
// A persisted pass order must name exactly the recorded identities;
|
||||
// otherwise the checkpoint's bucket index would address the wrong bucket.
|
||||
let valid_pass_order = state.replacement_bucket_pass_order.is_empty()
|
||||
|| state.replacement_bucket_incarnations.as_ref().is_some_and(|identities| {
|
||||
let ordered = state
|
||||
.replacement_bucket_pass_order
|
||||
.iter()
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
ordered.len() == state.replacement_bucket_pass_order.len()
|
||||
&& ordered.len() == identities.len()
|
||||
&& ordered.iter().all(|bucket| identities.contains_key(*bucket))
|
||||
});
|
||||
if !valid_execution_protocol
|
||||
|| !valid_pass_order
|
||||
|| state.replacement_retired_bucket_incarnations.values().any(Uuid::is_nil)
|
||||
if state.replacement_execution_protocol != 1
|
||||
|| state.replacement_predecessor.as_deref()
|
||||
!= state.replacement_lineage.last().map(|link| link.predecessor.as_str())
|
||||
{
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::heal::storage::HealStorageAPI;
|
||||
use crate::heal::storage_api::storage::DiskSetSelector;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
async fn namespace_snapshot(storage: &dyn HealStorageAPI, state: &ResumeState) -> Result<BTreeMap<String, Uuid>> {
|
||||
let (pool, set) = crate::heal::utils::parse_set_disk_id(&state.set_disk_id)?;
|
||||
// This API rejects partial topology and propagates read-quorum failures.
|
||||
let buckets = storage
|
||||
.list_buckets_for_admin_heal(Some(DiskSetSelector::new(pool, set)))
|
||||
.await?;
|
||||
let mut identities = BTreeMap::new();
|
||||
for bucket in buckets {
|
||||
let Some(incarnation) = storage.replacement_bucket_incarnation(&bucket.name).await? else {
|
||||
// A returning disk may still list a retired volume. Only the admitted
|
||||
// old UUID plus its durable receipt can discharge that obligation.
|
||||
if let Some(expected) = state
|
||||
.replacement_bucket_incarnations
|
||||
.as_ref()
|
||||
.and_then(|map| map.get(&bucket.name))
|
||||
.or_else(|| state.replacement_retired_bucket_incarnations.get(&bucket.name))
|
||||
&& storage.mrf_bucket_incarnation_retired(&bucket.name, *expected).await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Err(Error::ReplacementNamespaceChanged);
|
||||
};
|
||||
if incarnation.is_nil() {
|
||||
return Err(Error::InvalidBucketIncarnation);
|
||||
}
|
||||
identities.insert(bucket.name, incarnation);
|
||||
}
|
||||
Ok(identities)
|
||||
}
|
||||
|
||||
impl ResumeManager {
|
||||
pub(crate) async fn replacement_bucket_incarnation(&self, bucket: &str) -> Result<Uuid> {
|
||||
self.state
|
||||
.read()
|
||||
.await
|
||||
.replacement_bucket_incarnations
|
||||
.as_ref()
|
||||
.and_then(|identities| identities.get(bucket).copied())
|
||||
.ok_or(Error::ReplacementNamespaceChanged)
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_replacement_bucket_pass(&self, storage: &dyn HealStorageAPI) -> Result<Vec<String>> {
|
||||
let previous = self.get_state().await;
|
||||
let current = namespace_snapshot(storage, &previous).await?;
|
||||
// Recorded identities that are gone or replaced. Only a durable deletion
|
||||
// receipt discharges one; absence or a different identity alone does not.
|
||||
let mut retired = BTreeMap::new();
|
||||
if let Some(identities) = previous.replacement_bucket_incarnations.as_ref() {
|
||||
for (bucket, expected) in identities {
|
||||
if current.get(bucket) != Some(expected) {
|
||||
if !storage.mrf_bucket_incarnation_retired(bucket, *expected).await? {
|
||||
return Err(Error::ReplacementNamespaceChanged);
|
||||
}
|
||||
retired.insert(bucket.clone(), *expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
match previous.replacement_bucket_incarnations.as_ref() {
|
||||
// The first pass of this generation, or a recorded bucket was deleted
|
||||
// (and possibly recreated): the pass restarts over the current namespace.
|
||||
None => self.restart_replacement_bucket_pass(current, retired).await?,
|
||||
Some(_) if !retired.is_empty() => self.restart_replacement_bucket_pass(current, retired).await?,
|
||||
// Buckets created since the snapshot join the pass so their identity
|
||||
// is fenced like every other bucket, without discarding progress.
|
||||
Some(identities) => {
|
||||
let added = current
|
||||
.iter()
|
||||
.filter(|(bucket, _)| !identities.contains_key(*bucket))
|
||||
.map(|(bucket, incarnation)| (bucket.clone(), *incarnation))
|
||||
.collect::<Vec<_>>();
|
||||
if !added.is_empty() {
|
||||
let mut state = self.state.write().await;
|
||||
if state.replacement_bucket_pass_order.is_empty() {
|
||||
state.replacement_bucket_pass_order = identities.keys().cloned().collect();
|
||||
}
|
||||
for (bucket, incarnation) in added {
|
||||
state
|
||||
.replacement_bucket_incarnations
|
||||
.get_or_insert_with(BTreeMap::new)
|
||||
.insert(bucket.clone(), incarnation);
|
||||
state.replacement_bucket_pass_order.push(bucket.clone());
|
||||
state.pending_buckets.push(bucket);
|
||||
}
|
||||
drop(state);
|
||||
self.save_state_strict().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.get_state().await.replacement_bucket_pass_reset_pending {
|
||||
let checkpoint = if CheckpointManager::has_checkpoint(&self.disk, &previous.task_id).await {
|
||||
CheckpointManager::load_from_disk(self.disk.clone(), &previous.task_id).await?
|
||||
} else {
|
||||
CheckpointManager::new(self.disk.clone(), previous.task_id.clone()).await?
|
||||
};
|
||||
// Unlike the throttled retry writer this is durable before clearing the intent.
|
||||
checkpoint.reset_after_delete_recovery().await?;
|
||||
self.state.write().await.replacement_bucket_pass_reset_pending = false;
|
||||
self.save_state_strict().await?;
|
||||
}
|
||||
Ok(self.get_state().await.replacement_bucket_pass_buckets())
|
||||
}
|
||||
|
||||
/// Bind a fresh pass to `current`, publishing the checkpoint rewind first.
|
||||
async fn restart_replacement_bucket_pass(
|
||||
&self,
|
||||
current: BTreeMap<String, Uuid>,
|
||||
retired: BTreeMap<String, Uuid>,
|
||||
) -> Result<()> {
|
||||
let mut state = self.state.write().await;
|
||||
state.replacement_retired_bucket_incarnations.extend(retired);
|
||||
state.replacement_bucket_pass_order = current.keys().cloned().collect();
|
||||
state.pending_buckets = current.keys().cloned().collect();
|
||||
state.replacement_bucket_incarnations = Some(current);
|
||||
if state.replacement_generation.is_some() {
|
||||
// Protocol-1 readers reject this before interpreting a new pass cursor.
|
||||
state.replacement_execution_protocol = 2;
|
||||
}
|
||||
state.replacement_bucket_pass_reset_pending = true;
|
||||
state.reset_for_retry();
|
||||
state.total_objects = 0;
|
||||
state.total_bytes = 0;
|
||||
state.baseline_generation = None;
|
||||
state.baseline_known = false;
|
||||
drop(state);
|
||||
self.save_state_strict().await
|
||||
}
|
||||
|
||||
pub(crate) async fn verify_replacement_bucket_pass(&self, storage: &dyn HealStorageAPI) -> Result<()> {
|
||||
let state = self.get_state().await;
|
||||
let current = namespace_snapshot(storage, &state).await?;
|
||||
let Some(identities) = state.replacement_bucket_incarnations.as_ref() else {
|
||||
return Err(Error::ReplacementNamespaceChanged);
|
||||
};
|
||||
// Buckets created during the pass have nothing the pass could have
|
||||
// missed; only a recorded identity that is gone or replaced fails it.
|
||||
if identities
|
||||
.iter()
|
||||
.any(|(bucket, expected)| current.get(bucket) != Some(expected))
|
||||
{
|
||||
return Err(Error::ReplacementNamespaceChanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::heal::storage::{ECStoreHealStorage, HealStorageAPI};
|
||||
use crate::heal::storage_api::{
|
||||
storage::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions, ObjectIO as _},
|
||||
test_support::{
|
||||
ObjectOptions, PutObjReader, delete_config, init_bucket_metadata_sys, isolated_store_over_temp_disks, read_config,
|
||||
save_config,
|
||||
},
|
||||
};
|
||||
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn replacement_bucket_pass_retires_deleted_identity_and_restarts_recreated_bucket() {
|
||||
// Keep the concrete storage futures off the test executor stack.
|
||||
let (directories, store) = Box::pin(isolated_store_over_temp_disks()).await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "replacement-pass-bucket";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create original generation");
|
||||
let old = store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("original identity");
|
||||
let storage = ECStoreHealStorage::new(store.clone());
|
||||
let (_anchor_dir, disk) = super::super::tests::schema_test_disk().await;
|
||||
let task_id = ResumeUtils::generate_task_id();
|
||||
let manager = ResumeManager::new_replacement_intent(
|
||||
disk.clone(),
|
||||
task_id.clone(),
|
||||
"pool_0_set_0".to_string(),
|
||||
vec![bucket.to_owned()],
|
||||
vec!["replacement-a".to_string()],
|
||||
vec![ReplacementTargetIdentity {
|
||||
endpoint: "replacement-a".to_string(),
|
||||
canonical_path: "/mnt/replacement-a".to_string(),
|
||||
physical_device_ids: vec!["device-a".to_string()],
|
||||
filesystem_identity: "1:2:3".to_string(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("persist generation");
|
||||
// A legacy name-only cursor cannot be attributed to the currently live bucket.
|
||||
manager
|
||||
.set_resume_cursor(Some("unbound-legacy-cursor".to_string()))
|
||||
.await
|
||||
.expect("legacy cursor");
|
||||
let legacy_checkpoint = CheckpointManager::new(disk.clone(), task_id.clone())
|
||||
.await
|
||||
.expect("legacy checkpoint");
|
||||
legacy_checkpoint
|
||||
.add_processed_object("legacy-key".to_string())
|
||||
.await
|
||||
.expect("legacy dedup");
|
||||
legacy_checkpoint.complete_page(0, 99).await.expect("persist legacy cursor");
|
||||
assert_eq!(
|
||||
manager
|
||||
.prepare_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("admit original pass"),
|
||||
[bucket]
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.get_state()
|
||||
.await
|
||||
.replacement_bucket_incarnations
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(bucket))
|
||||
.copied(),
|
||||
Some(old)
|
||||
);
|
||||
assert!(
|
||||
manager.get_state().await.resume_cursor.is_none(),
|
||||
"legacy cursor must not enter the fresh pass"
|
||||
);
|
||||
let fresh_checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("fresh checkpoint")
|
||||
.get_checkpoint()
|
||||
.await;
|
||||
assert_eq!(fresh_checkpoint.current_object_index, 0);
|
||||
assert!(fresh_checkpoint.processed_objects.is_empty());
|
||||
manager
|
||||
.set_resume_cursor(Some("old-generation-cursor".to_string()))
|
||||
.await
|
||||
.expect("persist old cursor");
|
||||
let checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("checkpoint");
|
||||
checkpoint
|
||||
.add_processed_object("same-key".to_string())
|
||||
.await
|
||||
.expect("old dedup entry");
|
||||
checkpoint.complete_page(0, 7).await.expect("persist old position and dedup");
|
||||
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("delete original generation");
|
||||
assert!(matches!(
|
||||
manager.verify_replacement_bucket_pass(&storage).await,
|
||||
Err(Error::ReplacementNamespaceChanged)
|
||||
));
|
||||
manager
|
||||
.record_replacement_failure(&Error::ReplacementNamespaceChanged, 99)
|
||||
.await
|
||||
.expect("defer without exhausting replacement");
|
||||
assert_eq!(manager.get_state().await.retry_count, 0);
|
||||
let resumed = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("restart replacement manager");
|
||||
let retirement_path = format!("bucket-retirements/{bucket}/{old}.json");
|
||||
let retirement = Box::pin(read_config(store.clone(), &retirement_path))
|
||||
.await
|
||||
.expect("authoritative retirement");
|
||||
Box::pin(delete_config(store.clone(), &retirement_path))
|
||||
.await
|
||||
.expect("simulate missing retirement evidence");
|
||||
assert!(
|
||||
matches!(
|
||||
resumed.prepare_replacement_bucket_pass(&storage).await,
|
||||
Err(Error::ReplacementNamespaceChanged)
|
||||
),
|
||||
"physical absence alone cannot retire an admitted identity"
|
||||
);
|
||||
Box::pin(save_config(store.clone(), &retirement_path, b"corrupt retirement".to_vec()))
|
||||
.await
|
||||
.expect("inject unreadable proof");
|
||||
assert!(
|
||||
matches!(resumed.prepare_replacement_bucket_pass(&storage).await, Err(Error::Storage(_))),
|
||||
"a corrupt proof cannot be treated as an absent bucket"
|
||||
);
|
||||
assert_eq!(resumed.get_state().await.resume_cursor.as_deref(), Some("old-generation-cursor"));
|
||||
Box::pin(save_config(store.clone(), &retirement_path, retirement))
|
||||
.await
|
||||
.expect("restore proof");
|
||||
assert!(
|
||||
resumed
|
||||
.prepare_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("retirement proves deleted plan item")
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// A disk returning after DELETE may expose the retired physical volume
|
||||
// again; the persisted receipt identity still discharges that old name.
|
||||
tokio::fs::create_dir(directories[0].path().join(bucket))
|
||||
.await
|
||||
.expect("return stale retired volume");
|
||||
let returned = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("restart after retirement");
|
||||
assert!(
|
||||
returned
|
||||
.prepare_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("retired stale volume is not a new bucket")
|
||||
.is_empty()
|
||||
);
|
||||
tokio::fs::remove_dir(directories[0].path().join(bucket))
|
||||
.await
|
||||
.expect("remove stale test volume");
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate same name");
|
||||
let new = store.bucket_incarnation_id_from_disk(bucket).await.expect("new identity");
|
||||
assert_ne!(new, old);
|
||||
Box::pin(store.put_object(
|
||||
bucket,
|
||||
"same-key",
|
||||
&mut PutObjReader::from_vec(b"new payload".to_vec()),
|
||||
&ObjectOptions::default(),
|
||||
))
|
||||
.await
|
||||
.expect("new generation payload");
|
||||
assert_eq!(
|
||||
resumed
|
||||
.prepare_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("new generation gets independent pass"),
|
||||
[bucket]
|
||||
);
|
||||
let state = resumed.get_state().await;
|
||||
assert_eq!(
|
||||
state.replacement_execution_protocol, 2,
|
||||
"older binaries must reject identity-bound cursors"
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.replacement_bucket_incarnations
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(bucket))
|
||||
.copied(),
|
||||
Some(new)
|
||||
);
|
||||
assert!(state.resume_cursor.is_none());
|
||||
let checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("restarted checkpoint")
|
||||
.get_checkpoint()
|
||||
.await;
|
||||
assert_eq!((checkpoint.current_bucket_index, checkpoint.current_object_index), (0, 0));
|
||||
assert!(checkpoint.processed_objects.is_empty());
|
||||
|
||||
let opts = HealOpts {
|
||||
pool: Some(0),
|
||||
set: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
storage
|
||||
.heal_replacement_object_at_incarnation(bucket, "same-key", None, old, &opts, &CancellationToken::new())
|
||||
.await
|
||||
.is_err(),
|
||||
"a stale worker cannot mutate a recreated bucket"
|
||||
);
|
||||
let targets = directories
|
||||
.iter()
|
||||
.map(|dir| dir.path().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
storage
|
||||
.heal_replacement_bucket_metadata_at_incarnation(bucket, old, &opts, &targets)
|
||||
.await
|
||||
.is_err(),
|
||||
"a stale metadata pass cannot certify the new incarnation"
|
||||
);
|
||||
storage
|
||||
.heal_replacement_bucket_metadata_at_incarnation(bucket, new, &opts, &targets)
|
||||
.await
|
||||
.expect("current incarnation can repair metadata");
|
||||
let mut reader =
|
||||
Box::pin(store.get_object_reader(bucket, "same-key", None, Default::default(), &ObjectOptions::default()))
|
||||
.await
|
||||
.expect("open new generation payload");
|
||||
assert_eq!(reader.read_all().await.expect("read new payload"), b"new payload");
|
||||
drop(reader);
|
||||
assert_eq!(
|
||||
store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("new identity remains"),
|
||||
new
|
||||
);
|
||||
// Simulate interruption after publishing the reset obligation, before
|
||||
// replacing the old checkpoint.
|
||||
let stale_checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("checkpoint before interruption");
|
||||
stale_checkpoint
|
||||
.add_processed_object("stale-before-reset".to_string())
|
||||
.await
|
||||
.expect("stale dedup");
|
||||
stale_checkpoint
|
||||
.complete_page(0, 42)
|
||||
.await
|
||||
.expect("persist interrupted checkpoint");
|
||||
resumed.state.write().await.replacement_bucket_pass_reset_pending = true;
|
||||
resumed.save_state_strict().await.expect("persist reset obligation");
|
||||
let interrupted = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("restart interrupted pass");
|
||||
interrupted
|
||||
.prepare_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("finish interrupted checkpoint rewind");
|
||||
let repaired_checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("repaired checkpoint")
|
||||
.get_checkpoint()
|
||||
.await;
|
||||
assert_eq!(repaired_checkpoint.current_object_index, 0);
|
||||
assert!(repaired_checkpoint.processed_objects.is_empty());
|
||||
assert!(!interrupted.get_state().await.replacement_bucket_pass_reset_pending);
|
||||
let loaded = ResumeManager::load_replacement_intent(disk, &task_id)
|
||||
.await
|
||||
.expect("new pass survives restart");
|
||||
loaded
|
||||
.verify_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("new pass matches current namespace");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn replacement_bucket_pass_keeps_progress_when_a_bucket_is_created_mid_pass() {
|
||||
let (_directories, store) = Box::pin(isolated_store_over_temp_disks()).await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let recorded = "pass-recorded";
|
||||
store
|
||||
.make_bucket(recorded, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create the recorded bucket");
|
||||
let storage = ECStoreHealStorage::new(store.clone());
|
||||
let (_anchor_dir, disk) = super::super::tests::schema_test_disk().await;
|
||||
let task_id = ResumeUtils::generate_task_id();
|
||||
let manager = ResumeManager::new_replacement_intent(
|
||||
disk.clone(),
|
||||
task_id.clone(),
|
||||
"pool_0_set_0".to_string(),
|
||||
vec![recorded.to_owned()],
|
||||
vec!["replacement-a".to_string()],
|
||||
vec![ReplacementTargetIdentity {
|
||||
endpoint: "replacement-a".to_string(),
|
||||
canonical_path: "/mnt/replacement-a".to_string(),
|
||||
physical_device_ids: vec!["device-a".to_string()],
|
||||
filesystem_identity: "1:2:3".to_string(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("persist generation");
|
||||
assert_eq!(
|
||||
manager
|
||||
.prepare_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("admit the recorded pass"),
|
||||
[recorded]
|
||||
);
|
||||
// Progress into the recorded bucket.
|
||||
manager
|
||||
.set_resume_cursor(Some("mid-pass-cursor".to_string()))
|
||||
.await
|
||||
.expect("persist mid-pass cursor");
|
||||
let checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("admitted checkpoint");
|
||||
checkpoint.complete_page(0, 7).await.expect("persist mid-pass position");
|
||||
|
||||
// The new name sorts before the recorded one, so a sorted rebuild would
|
||||
// move the recorded bucket away from the checkpoint's index.
|
||||
let created = "pass-created";
|
||||
store
|
||||
.make_bucket(created, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create a bucket during the pass");
|
||||
let created_identity = store
|
||||
.bucket_incarnation_id_from_disk(created)
|
||||
.await
|
||||
.expect("created identity");
|
||||
|
||||
manager
|
||||
.verify_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("a bucket created mid-pass does not fail verification");
|
||||
assert_eq!(
|
||||
manager
|
||||
.prepare_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("additions extend the pass"),
|
||||
[recorded, created],
|
||||
"additions are appended so the checkpoint's bucket index keeps its meaning"
|
||||
);
|
||||
let state = manager.get_state().await;
|
||||
assert_eq!(
|
||||
state
|
||||
.replacement_bucket_incarnations
|
||||
.as_ref()
|
||||
.and_then(|identities| identities.get(created))
|
||||
.copied(),
|
||||
Some(created_identity)
|
||||
);
|
||||
assert!(state.pending_buckets.contains(&created.to_string()));
|
||||
assert_eq!(state.resume_cursor.as_deref(), Some("mid-pass-cursor"));
|
||||
assert!(!state.replacement_bucket_pass_reset_pending);
|
||||
assert_eq!(state.replacement_execution_protocol, 2);
|
||||
let checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("checkpoint after the addition")
|
||||
.get_checkpoint()
|
||||
.await;
|
||||
assert_eq!(
|
||||
(checkpoint.current_bucket_index, checkpoint.current_object_index),
|
||||
(0, 7),
|
||||
"the checkpoint must not rewind"
|
||||
);
|
||||
|
||||
// A restart reads the persisted order instead of rebuilding it sorted.
|
||||
let restarted = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("restart the pass");
|
||||
assert_eq!(
|
||||
restarted
|
||||
.prepare_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("an unchanged namespace keeps the pass"),
|
||||
[recorded, created]
|
||||
);
|
||||
restarted
|
||||
.verify_replacement_bucket_pass(&storage)
|
||||
.await
|
||||
.expect("the extended pass matches the namespace");
|
||||
assert_eq!(restarted.get_state().await.resume_cursor.as_deref(), Some("mid-pass-cursor"));
|
||||
|
||||
// Deleting the created bucket is still a namespace change for the pass.
|
||||
store
|
||||
.delete_bucket(created, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("delete the created bucket");
|
||||
assert!(matches!(
|
||||
restarted.verify_replacement_bucket_pass(&storage).await,
|
||||
Err(Error::ReplacementNamespaceChanged)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@ impl ResumeManager {
|
||||
}
|
||||
return self.ensure_handoff_successor().await;
|
||||
}
|
||||
if !matches!(state.replacement_execution_protocol, 1 | 2)
|
||||
if state.replacement_execution_protocol != 1
|
||||
|| !matches!(
|
||||
state.replacement_phase,
|
||||
ReplacementPhase::Intent
|
||||
@@ -458,13 +458,6 @@ impl ResumeManager {
|
||||
predecessor.replacement_targets.clone(),
|
||||
link.targets.clone(),
|
||||
);
|
||||
if predecessor.replacement_execution_protocol == 2 {
|
||||
state.replacement_execution_protocol = 2;
|
||||
state.replacement_bucket_incarnations = predecessor.replacement_bucket_incarnations.clone();
|
||||
state.replacement_bucket_pass_order = predecessor.replacement_bucket_pass_order.clone();
|
||||
state.replacement_retired_bucket_incarnations = predecessor.replacement_retired_bucket_incarnations.clone();
|
||||
state.replacement_bucket_pass_reset_pending = true;
|
||||
}
|
||||
state.replacement_phase = ReplacementPhase::OwnershipPending;
|
||||
// The constructor marks this compatibility grant consumed for every
|
||||
// successor, including descendants of a recovered older generation.
|
||||
@@ -587,7 +580,7 @@ fn preformat_handoff_retry_candidate(state: &ResumeState) -> bool {
|
||||
&& state.replacement_generation.as_deref() == Some(state.task_id.as_str())
|
||||
&& !state.replacement_preformat_handoff_retry_done
|
||||
&& state.replacement_phase == ReplacementPhase::Intent
|
||||
&& matches!(state.replacement_execution_protocol, 1 | 2)
|
||||
&& state.replacement_execution_protocol == 1
|
||||
&& state.max_retries >= 2
|
||||
&& state.replacement_predecessor.is_none()
|
||||
&& state.replacement_handoff.is_none()
|
||||
|
||||
@@ -918,33 +918,3 @@ async fn committed_predecessor_is_collectible_once_its_successor_is_verified() {
|
||||
"the verified successor keeps its own record until cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handoff_preserves_bucket_pass_protocol_and_rewinds_successor_progress() {
|
||||
let (_dirs, parent, execution) = fixture(1).await;
|
||||
let identities = [("bucket-a".to_string(), Uuid::new_v4())].into_iter().collect();
|
||||
{
|
||||
let mut state = parent.state.write().await;
|
||||
state.replacement_execution_protocol = 2;
|
||||
state.replacement_bucket_incarnations = Some(identities);
|
||||
state.resume_cursor = Some("old-pass-cursor".to_string());
|
||||
}
|
||||
parent.save_state_strict().await.expect("persist identity-bound predecessor");
|
||||
let child = parent
|
||||
.prepare_replacement_handoff(&execution, Vec::new())
|
||||
.await
|
||||
.expect("handoff protocol two");
|
||||
let state = child.get_state().await;
|
||||
assert_eq!(state.replacement_execution_protocol, 2);
|
||||
assert_eq!(
|
||||
state.replacement_bucket_incarnations,
|
||||
parent.get_state().await.replacement_bucket_incarnations
|
||||
);
|
||||
assert!(state.replacement_bucket_pass_reset_pending);
|
||||
assert!(state.resume_cursor.is_none());
|
||||
assert!(state.completed_buckets.is_empty());
|
||||
let recovered = ResumeManager::load_replacement_intent(child.disk.clone(), &state.task_id)
|
||||
.await
|
||||
.expect("reload protocol-two successor");
|
||||
assert_eq!(recovered.get_state().await, state);
|
||||
}
|
||||
|
||||
@@ -345,16 +345,6 @@ pub(crate) fn merge_replacement_recovery_candidate(
|
||||
|
||||
let candidate_revision = candidate.state.replacement_revision;
|
||||
let current_revision = current.state.replacement_revision;
|
||||
let candidate_protocol = candidate.state.replacement_execution_protocol;
|
||||
let current_protocol = current.state.replacement_execution_protocol;
|
||||
if (candidate_protocol > current_protocol && candidate_revision <= current_revision)
|
||||
|| (candidate_protocol < current_protocol && candidate_revision >= current_revision)
|
||||
{
|
||||
return Err(Error::ReplacementGenerationConflict {
|
||||
task_id: candidate.state.task_id,
|
||||
reason: "replacement execution protocol regresses with durable revision".to_string(),
|
||||
});
|
||||
}
|
||||
if candidate_revision == current_revision && candidate.state != current.state {
|
||||
return Err(Error::ReplacementGenerationConflict {
|
||||
task_id: candidate.state.task_id,
|
||||
@@ -384,10 +374,7 @@ fn same_replacement_generation_binding(left: &ResumeState, right: &ResumeState)
|
||||
&& left.replacement_target_identities == right.replacement_target_identities
|
||||
&& left.replacement_buckets == right.replacement_buckets
|
||||
&& left.replacement_generation == right.replacement_generation
|
||||
// A revision-ordered protocol-1 -> protocol-2 pass admission preserves
|
||||
// the generation's target ownership and immutable lineage.
|
||||
&& matches!(left.replacement_execution_protocol, 1 | 2)
|
||||
&& matches!(right.replacement_execution_protocol, 1 | 2)
|
||||
&& left.replacement_execution_protocol == right.replacement_execution_protocol
|
||||
&& left.replacement_predecessor == right.replacement_predecessor
|
||||
&& left.replacement_lineage == right.replacement_lineage
|
||||
&& left.replacement_legacy_import == right.replacement_legacy_import
|
||||
@@ -605,12 +592,6 @@ impl ResumeManager {
|
||||
if state.replacement_generation.as_deref() != Some(state.task_id.as_str()) {
|
||||
return Err(replacement_recovery_conflict("replacement failure has no matching generation"));
|
||||
}
|
||||
if matches!(error, Error::ReplacementNamespaceChanged | Error::BucketDiscoveryIncomplete) {
|
||||
// Namespace churn is not evidence that the replacement is irreparable.
|
||||
state.error_message = Some(error.to_string());
|
||||
drop(state);
|
||||
return self.save_state_strict().await;
|
||||
}
|
||||
if let Error::DanglingDeleteDeferred { retry_not_before } = error {
|
||||
if !super::dangling_delete_deadline_is_representable(*retry_not_before) {
|
||||
return Err(Error::TaskExecutionFailed {
|
||||
|
||||
@@ -3629,129 +3629,3 @@ async fn blocked_checkpoint_is_not_revalidated_before_its_schedule() {
|
||||
assert_eq!(serde_json::from_slice::<serde_json::Value>(&retained).expect("marker JSON"), marker);
|
||||
temp_dir.close().expect("remove scheduled revalidation fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_candidate_merge_rejects_protocol_downgrade_in_either_read_order() {
|
||||
for downgrade in [false, true] {
|
||||
for reverse in [false, true] {
|
||||
let task_id = ResumeUtils::generate_task_id();
|
||||
let mut protocol_one = replacement_candidate_state(&task_id);
|
||||
protocol_one.replacement_revision = if downgrade { 11 } else { 9 };
|
||||
let mut protocol_two = replacement_candidate_state(&task_id);
|
||||
protocol_two.replacement_execution_protocol = 2;
|
||||
protocol_two.replacement_bucket_incarnations = Some(Default::default());
|
||||
protocol_two.replacement_revision = 10;
|
||||
let (first, second) = if reverse {
|
||||
(protocol_two, protocol_one)
|
||||
} else {
|
||||
(protocol_one, protocol_two)
|
||||
};
|
||||
let mut selected = None;
|
||||
merge_replacement_recovery_candidate(
|
||||
&mut selected,
|
||||
ReplacementRecoveryCandidate::new(first, "anchor-a", false).expect("first durable copy"),
|
||||
)
|
||||
.expect("initial observation");
|
||||
let result = merge_replacement_recovery_candidate(
|
||||
&mut selected,
|
||||
ReplacementRecoveryCandidate::new(second, "anchor-b", true).expect("second durable copy"),
|
||||
);
|
||||
if downgrade {
|
||||
assert!(
|
||||
matches!(result, Err(Error::ReplacementGenerationConflict { .. })),
|
||||
"protocol 1 at a newer revision must not erase protocol 2, reverse={reverse}"
|
||||
);
|
||||
} else {
|
||||
result.expect("protocol 1 at an older revision may advance to protocol 2");
|
||||
let state = selected.expect("selected upgraded generation").state;
|
||||
assert_eq!(state.replacement_execution_protocol, 2);
|
||||
assert_eq!(state.replacement_revision, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn replacement_recovery_survives_same_named_bucket_deletion_and_recreation() {
|
||||
use crate::heal::storage_api::{
|
||||
storage::{BucketOperations as _, DeleteBucketOptions, DiskSetSelector, MakeBucketOptions, StorageAdminApi as _},
|
||||
test_support::{init_bucket_metadata_sys, isolated_store_over_temp_disks},
|
||||
};
|
||||
|
||||
let (_directories, store) = Box::pin(isolated_store_over_temp_disks()).await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "ahm-replacement";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create legal same-name bucket");
|
||||
let disk = store
|
||||
.disk_set_inventory(DiskSetSelector::new(0, 0))
|
||||
.await
|
||||
.expect("survivor inventory")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.next()
|
||||
.expect("survivor disk");
|
||||
let task_id = ResumeUtils::generate_task_id();
|
||||
let manager = ResumeManager::new_replacement_intent(
|
||||
disk.clone(),
|
||||
task_id.clone(),
|
||||
"pool_0_set_0".to_string(),
|
||||
vec!["other-bucket".to_string()],
|
||||
vec!["replacement-a".to_string()],
|
||||
vec![ReplacementTargetIdentity {
|
||||
endpoint: "replacement-a".to_string(),
|
||||
canonical_path: "/mnt/replacement-a".to_string(),
|
||||
physical_device_ids: vec!["device-a".to_string()],
|
||||
filesystem_identity: "1:2:3".to_string(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("persist sealed replacement intent");
|
||||
let expected = manager.get_state().await;
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("delete same-name user bucket");
|
||||
let resumed = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("sealed intent remains recoverable");
|
||||
assert_eq!(resumed.get_state().await, expected);
|
||||
assert_eq!(
|
||||
ResumeUtils::get_replacement_intent_tasks(&disk)
|
||||
.await
|
||||
.expect("scanner recovery enumeration"),
|
||||
std::slice::from_ref(&task_id)
|
||||
);
|
||||
resumed
|
||||
.mark_replacement_completed_and_verified()
|
||||
.await
|
||||
.expect("replacement can finish after deletion");
|
||||
resumed
|
||||
.ensure_replacement_completion_proof()
|
||||
.await
|
||||
.expect("persist completion proof");
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate same-name user bucket");
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("delete recreated bucket");
|
||||
let proof = ResumeManager::load_replacement_completion_proof(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("completion proof survives recreation");
|
||||
assert_eq!(proof.task_id, task_id);
|
||||
assert_eq!(
|
||||
ResumeManager::load_replacement_intent(disk, &task_id)
|
||||
.await
|
||||
.expect("verified intent survives")
|
||||
.get_state()
|
||||
.await
|
||||
.replacement_phase,
|
||||
ReplacementPhase::Verified
|
||||
);
|
||||
}
|
||||
|
||||
@@ -476,15 +476,6 @@ pub trait HealStorageAPI: Send + Sync {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Published identity for a replacement pass; None proves missing metadata only.
|
||||
async fn replacement_bucket_incarnation(&self, bucket: &str) -> Result<Option<Uuid>> {
|
||||
match self.admit_bucket_incarnation(bucket).await {
|
||||
Ok(id) => Ok(Some(id)),
|
||||
Err(Error::StaleBucketIncarnation { .. }) => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the exact old bucket generation has a durable deletion record.
|
||||
/// Alternate backends must fail closed unless they can validate the same
|
||||
/// deployment, bucket and incarnation identity.
|
||||
@@ -607,23 +598,6 @@ pub trait HealStorageAPI: Send + Sync {
|
||||
self.heal_object(bucket, object, version_id, opts).await
|
||||
}
|
||||
|
||||
/// Replacement writes retain both cancellation ownership and the admitted bucket fence.
|
||||
async fn heal_replacement_object_at_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<&str>,
|
||||
expected: Uuid,
|
||||
opts: &HealOpts,
|
||||
task_cancel: &CancellationToken,
|
||||
) -> Result<HealStorageObjectResult> {
|
||||
if task_cancel.is_cancelled() {
|
||||
return Err(Error::TaskCancelled);
|
||||
}
|
||||
self.heal_object_at_incarnation(bucket, object, version_id, expected, opts)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn heal_object_with_receipt(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -689,17 +663,6 @@ pub trait HealStorageAPI: Send + Sync {
|
||||
Err(Error::Storage(StorageError::PreconditionFailed))
|
||||
}
|
||||
|
||||
async fn heal_replacement_bucket_metadata_at_incarnation(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_expected: Uuid,
|
||||
_opts: &HealOpts,
|
||||
_targets: &[String],
|
||||
) -> Result<()> {
|
||||
// An unfenced fallback could certify a different same-name bucket.
|
||||
Err(Error::Storage(StorageError::PreconditionFailed))
|
||||
}
|
||||
|
||||
/// Read target-specific physical evidence for one replacement version.
|
||||
///
|
||||
/// This is only used by automatic replacement healing after the normal
|
||||
@@ -1353,35 +1316,6 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn heal_replacement_object_at_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<&str>,
|
||||
expected: Uuid,
|
||||
opts: &HealOpts,
|
||||
task_cancel: &CancellationToken,
|
||||
) -> Result<HealStorageObjectResult> {
|
||||
run_background_object_inner(
|
||||
self.admission.as_ref(),
|
||||
|| self.ecstore.physical_heal_plan(object, opts).map_err(Error::Storage),
|
||||
opts,
|
||||
Some(task_cancel),
|
||||
|result: &HealStorageObjectResult| result.error.is_none(),
|
||||
boxed_object_operation(|| async {
|
||||
let result = self
|
||||
.ecstore
|
||||
.heal_object_at_incarnation(bucket, object, version_id.unwrap_or_default(), expected, opts)
|
||||
.await
|
||||
.map_err(|error| incarnation_storage_error(bucket, expected, error))?;
|
||||
Ok(self
|
||||
.object_result_with_receipt(bucket, object, version_id, opts, result, Some(expected))
|
||||
.await)
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn heal_mrf_object_at_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -1586,13 +1520,6 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async fn replacement_bucket_incarnation(&self, bucket: &str) -> Result<Option<Uuid>> {
|
||||
self.ecstore
|
||||
.replacement_bucket_incarnation_from_disk(bucket)
|
||||
.await
|
||||
.map_err(Error::Storage)
|
||||
}
|
||||
|
||||
async fn mrf_bucket_incarnation_retired(&self, bucket: &str, expected: Uuid) -> Result<bool> {
|
||||
self.ecstore
|
||||
.is_bucket_incarnation_retired(bucket, expected)
|
||||
@@ -2032,19 +1959,6 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
.map_err(Error::Storage)
|
||||
}
|
||||
|
||||
async fn heal_replacement_bucket_metadata_at_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
expected: Uuid,
|
||||
opts: &HealOpts,
|
||||
targets: &[String],
|
||||
) -> Result<()> {
|
||||
self.ecstore
|
||||
.heal_replacement_bucket_metadata_at_incarnation(bucket, expected, opts, targets)
|
||||
.await
|
||||
.map_err(|error| incarnation_storage_error(bucket, expected, error))
|
||||
}
|
||||
|
||||
async fn replacement_targets_have_version(
|
||||
&self,
|
||||
bucket: &str,
|
||||
|
||||
@@ -22,9 +22,8 @@ pub(crate) use rustfs_ecstore::api::disk::error::{DiskError as EcstoreDiskError,
|
||||
pub(crate) use rustfs_ecstore::api::disk::{
|
||||
BUCKET_META_PREFIX as ECSTORE_BUCKET_META_PREFIX, Bytes as EcstoreDiskBytes,
|
||||
ConditionalFileUpdate as EcstoreConditionalFileUpdate, DeleteOptions as EcstoreDeleteOptions, DiskAPI as EcstoreDiskAPI,
|
||||
DiskStore as EcstoreDiskStore, HEAL_REPLACEMENT_RECOVERY_DIR as ECSTORE_HEAL_REPLACEMENT_RECOVERY_DIR,
|
||||
HEALING_MARKER_PATH as ECSTORE_HEALING_MARKER_PATH, RUSTFS_META_BUCKET as ECSTORE_RUSTFS_META_BUCKET,
|
||||
ReplacementExecutionLease as EcstoreReplacementExecutionLease,
|
||||
DiskStore as EcstoreDiskStore, HEALING_MARKER_PATH as ECSTORE_HEALING_MARKER_PATH,
|
||||
RUSTFS_META_BUCKET as ECSTORE_RUSTFS_META_BUCKET, ReplacementExecutionLease as EcstoreReplacementExecutionLease,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{
|
||||
@@ -81,11 +80,11 @@ pub(crate) mod owner {
|
||||
pub(crate) use super::storage_contracts::{ObjectIO, ObjectOperations};
|
||||
|
||||
pub(crate) use super::{
|
||||
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEAL_REPLACEMENT_RECOVERY_DIR,
|
||||
ECSTORE_HEALING_MARKER_PATH, ECSTORE_POOL_META_NAME, ECSTORE_RUSTFS_META_BUCKET, EcstoreConditionalFileUpdate,
|
||||
EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError, EcstoreDiskResult, EcstoreDiskStore,
|
||||
EcstoreEndpoint, EcstoreErrorType, EcstoreHealLifecycleExpiryContext, EcstoreStorageError, EcstoreStore,
|
||||
ecstore_is_reserved_or_invalid_bucket, ecstore_load_admin_data_usage_from_backend_cached, ecstore_local_disk_map_read,
|
||||
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_POOL_META_NAME,
|
||||
ECSTORE_RUSTFS_META_BUCKET, EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes,
|
||||
EcstoreDiskError, EcstoreDiskResult, EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType,
|
||||
EcstoreHealLifecycleExpiryContext, EcstoreStorageError, EcstoreStore, ecstore_is_reserved_or_invalid_bucket,
|
||||
ecstore_load_admin_data_usage_from_backend_cached, ecstore_local_disk_map_read,
|
||||
};
|
||||
|
||||
pub(crate) use super::{EcstoreDiskOption, ecstore_new_disk};
|
||||
@@ -99,19 +98,10 @@ pub(crate) mod storage {
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use super::storage_contracts::{DeleteBucketOptions, MakeBucketOptions};
|
||||
pub(crate) use super::storage_contracts::DeleteBucketOptions;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod status {
|
||||
pub(crate) use super::storage_contracts::BucketInfo;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::{
|
||||
init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks,
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::config::com::{delete_config, read_config, save_config};
|
||||
pub(crate) use rustfs_ecstore::api::object::{ObjectOptions, PutObjReader};
|
||||
}
|
||||
|
||||
@@ -1310,22 +1310,7 @@ impl HealTask {
|
||||
.load(Ordering::Acquire)
|
||||
.max(self.retry_attempts)
|
||||
.saturating_add(1);
|
||||
// A deletion can race any stage, including hidden-transaction recovery.
|
||||
// Only a successful complete inventory may reclassify that failure;
|
||||
// lookup/quorum failures remain the original storage failure.
|
||||
if manager.get_state().await.replacement_bucket_incarnations.is_some()
|
||||
&& !matches!(failure, Error::TaskCancelled | Error::TaskTimeout)
|
||||
&& matches!(
|
||||
manager.verify_replacement_bucket_pass(self.storage.as_ref()).await,
|
||||
Err(Error::ReplacementNamespaceChanged)
|
||||
)
|
||||
{
|
||||
manager
|
||||
.record_replacement_failure(&Error::ReplacementNamespaceChanged, attempt)
|
||||
.await
|
||||
} else {
|
||||
manager.record_replacement_failure(&failure, attempt).await
|
||||
}
|
||||
manager.record_replacement_failure(&failure, attempt).await
|
||||
}
|
||||
.await;
|
||||
match persisted {
|
||||
|
||||
@@ -247,12 +247,7 @@ impl HealTask {
|
||||
*self.replacement_resume_disk.write().await = Some(disk.clone());
|
||||
let state = manager.get_state().await;
|
||||
self.replacement_start_retry_count.store(state.retry_count, Ordering::Release);
|
||||
buckets = if state.completed {
|
||||
state.replacement_buckets
|
||||
} else {
|
||||
self.await_with_control(manager.prepare_replacement_bucket_pass(self.storage.as_ref()))
|
||||
.await?
|
||||
};
|
||||
buckets = state.replacement_buckets;
|
||||
Some((disk, manager, identities))
|
||||
} else {
|
||||
None
|
||||
@@ -483,31 +478,14 @@ impl HealTask {
|
||||
self.verify_replacement_identity_fence(expected_identities, &set_disk_id, "bucket prepass")
|
||||
.await?;
|
||||
}
|
||||
let expected = if let Some((_, manager, _)) = replacement_resume.as_ref() {
|
||||
Some(manager.replacement_bucket_incarnation(bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let heal_result = self
|
||||
.await_with_control(async {
|
||||
match expected {
|
||||
Some(id) => self.storage.heal_bucket_at_incarnation(bucket, id, &bucket_heal_opts).await,
|
||||
None => self.storage.heal_bucket(bucket, &bucket_heal_opts).await,
|
||||
}
|
||||
})
|
||||
.await_with_control(self.storage.heal_bucket(bucket, &bucket_heal_opts))
|
||||
.await;
|
||||
match heal_result {
|
||||
Ok(result) => {
|
||||
self.record_result_item(result).await;
|
||||
}
|
||||
Err(err) => {
|
||||
// Cancellation and timeout keep their classification; only
|
||||
// a storage failure may be reclassified as a namespace change.
|
||||
if let Some((_, manager, _)) = replacement_resume.as_ref()
|
||||
&& !matches!(err, Error::TaskCancelled | Error::TaskTimeout)
|
||||
{
|
||||
manager.verify_replacement_bucket_pass(self.storage.as_ref()).await?;
|
||||
}
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_ERASURE_SET_RESULT,
|
||||
@@ -527,7 +505,7 @@ impl HealTask {
|
||||
if let Some(result) = self
|
||||
.await_with_control(
|
||||
self.storage
|
||||
.recover_delete_transactions(bucket, None, expected, &bucket_heal_opts),
|
||||
.recover_delete_transactions(bucket, None, None, &bucket_heal_opts),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
|
||||
@@ -1056,7 +1056,6 @@ async fn automatic_replacement_uses_target_scoped_format() {
|
||||
let temp = TempDir::new().expect("temporary resume disk directory should be created");
|
||||
let disk = make_resume_disk(&temp).await;
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_id: Mutex::new(Some(Uuid::from_u128(1))),
|
||||
replacement_target_identities_ready: Mutex::new(true),
|
||||
resume_disk: Mutex::new(Some(disk.clone())),
|
||||
..Default::default()
|
||||
@@ -1265,7 +1264,6 @@ async fn automatic_replacement_rejects_a_new_identity_after_format() {
|
||||
let first_identity = replacement_identity("replacement-a", "device-a", "filesystem-a");
|
||||
let second_identity = replacement_identity("replacement-a", "device-b", "filesystem-b");
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_id: Mutex::new(Some(Uuid::from_u128(1))),
|
||||
replacement_target_identities_ready: Mutex::new(true),
|
||||
replacement_target_identity_sequences: Mutex::new(VecDeque::from([
|
||||
vec![first_identity.clone()],
|
||||
@@ -1332,7 +1330,6 @@ async fn automatic_replacement_reuses_an_existing_non_target_resume_anchor() {
|
||||
.await
|
||||
.expect("existing intent should be stored on the non-target anchor");
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_id: Mutex::new(Some(Uuid::from_u128(1))),
|
||||
replacement_target_identities_ready: Mutex::new(true),
|
||||
replacement_resume_disk: Mutex::new(Some(anchor.clone())),
|
||||
..Default::default()
|
||||
@@ -3005,7 +3002,6 @@ async fn replacement_dropped_waiter_keeps_executor_until_format_and_failure_pers
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_id: Mutex::new(Some(Uuid::from_u128(1))),
|
||||
replacement_target_identities_ready: Mutex::new(true),
|
||||
replacement_target_identity_sequences: Mutex::new(VecDeque::from([vec![identity.clone()], vec![identity]])),
|
||||
replacement_execution_fixture: Mutex::new(Some(execution)),
|
||||
@@ -3020,9 +3016,7 @@ async fn replacement_dropped_waiter_keeps_executor_until_format_and_failure_pers
|
||||
let task = task.clone();
|
||||
async move { task.execute().await }
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.expect("owned format I/O must start");
|
||||
entered.notified().await;
|
||||
waiter.abort();
|
||||
assert!(waiter.await.expect_err("waiter aborted").is_cancelled());
|
||||
task.cancel_token.cancel();
|
||||
@@ -3132,7 +3126,6 @@ async fn replacement_format_failures_keep_the_durable_budget_across_new_executor
|
||||
request.heal_endpoints = vec!["replacement-a".to_string()];
|
||||
for _attempt in 1..=3 {
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_id: Mutex::new(Some(Uuid::from_u128(1))),
|
||||
replacement_target_identities_ready: Mutex::new(true),
|
||||
resume_disk: Mutex::new(Some(disk.clone())),
|
||||
format_error: Mutex::new(Some(Error::other("injected format failure"))),
|
||||
@@ -5694,7 +5687,6 @@ async fn replacement_owned_executor_keeps_manager_drain_pending_through_failure_
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_id: Mutex::new(Some(Uuid::from_u128(1))),
|
||||
replacement_target_identities_ready: Mutex::new(true),
|
||||
replacement_target_identity_sequences: Mutex::new(VecDeque::from([vec![identity.clone()], vec![identity]])),
|
||||
replacement_execution_fixture: Mutex::new(Some(execution)),
|
||||
@@ -5711,9 +5703,7 @@ async fn replacement_owned_executor_keeps_manager_drain_pending_through_failure_
|
||||
let task = task.clone();
|
||||
async move { task.execute_owned(Some(parent)).await }
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.expect("owned format I/O must start");
|
||||
entered.notified().await;
|
||||
waiter.abort();
|
||||
assert!(waiter.await.expect_err("waiter aborted").is_cancelled());
|
||||
task.cancel_token.cancel();
|
||||
|
||||
@@ -440,19 +440,6 @@ impl PoolTier {
|
||||
}
|
||||
}
|
||||
|
||||
impl PooledBuffer {
|
||||
/// A buffer outside the pool: it holds no permit and is never returned to
|
||||
/// a tier. Callers use it when the pool is at capacity but the request
|
||||
/// must not be refused.
|
||||
pub fn unpooled(size: usize) -> Self {
|
||||
Self {
|
||||
buffer: ManuallyDrop::new(BytesMut::with_capacity(size)),
|
||||
tier: None,
|
||||
_permit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PooledBuffer {
|
||||
// SAFETY: Drop has exclusive access to `self`; taking the `ManuallyDrop`
|
||||
// buffer moves it exactly once into the pool when a tier still owns it.
|
||||
|
||||
@@ -58,7 +58,6 @@ const ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_ALL_VERSIONS: &str =
|
||||
"Days must be a positive integer and Date must not be specified inside Expiration with ExpiredObjectAllVersions";
|
||||
const ERR_LIFECYCLE_INVALID_DEL_MARKER_EXPIRATION_DAYS: &str = "Days must be a positive integer with DelMarkerExpiration";
|
||||
const ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG: &str = "Rule ID must be at most 255 characters";
|
||||
const ERR_LIFECYCLE_INVALID_RULE_ID_CHARS: &str = "Lifecycle rule ID must not contain control characters";
|
||||
const ERR_LIFECYCLE_INVALID_RULE_ID_EMPTY: &str = "Rule ID must not be empty";
|
||||
const ERR_LIFECYCLE_INVALID_RULE_STATUS: &str = "Rule status must be either Enabled or Disabled";
|
||||
const ERR_LIFECYCLE_DEL_MARKER_WITH_TAGS: &str = "Rule with DelMarkerExpiration cannot have tags based filtering";
|
||||
@@ -584,11 +583,6 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
||||
if id.len() > 255 {
|
||||
return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG));
|
||||
}
|
||||
// The ID is echoed in the x-amz-expiration header; a control
|
||||
// character there would make every object GET, HEAD and PUT fail.
|
||||
if id.chars().any(char::is_control) {
|
||||
return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_RULE_ID_CHARS));
|
||||
}
|
||||
}
|
||||
r.validate()?;
|
||||
if let Some(object_lock_enabled) = lr.object_lock_enabled.as_ref()
|
||||
@@ -2052,34 +2046,6 @@ mod tests {
|
||||
assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_rejects_rule_id_with_control_characters() {
|
||||
for id in ["rule\nid", "rule\rid", "rule\tid", "rule\u{1b}id"] {
|
||||
let lc = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(30),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some(id.to_owned()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err();
|
||||
|
||||
assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_RULE_ID_CHARS, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_rejects_duplicate_rule_ids() {
|
||||
let lc = BucketLifecycleConfiguration {
|
||||
|
||||
@@ -45,7 +45,6 @@ hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-io-metrics/hotpath-cpu"
|
||||
hotpath.workspace = true
|
||||
rustfs-io-metrics = { workspace = true }
|
||||
rustfs-utils = { workspace = true }
|
||||
rustfs-common = { workspace = true }
|
||||
async-trait.workspace = true
|
||||
futures.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -31,13 +31,10 @@ pub mod manager_trait;
|
||||
pub mod metrics;
|
||||
pub mod object_pool;
|
||||
pub mod optimized_notify;
|
||||
mod publication;
|
||||
pub mod shard;
|
||||
pub mod state;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod publication_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
@@ -95,8 +95,6 @@ impl ObjectLockState {
|
||||
// Reset atomic state
|
||||
self.atomic_state = crate::fast_lock::state::AtomicLockState::new();
|
||||
|
||||
self.publication = std::sync::OnceLock::new();
|
||||
|
||||
// Clear owners
|
||||
*self.current_owner.write() = None;
|
||||
self.shared_owners.write().clear();
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! FIFO admission only for the bucket publication fence. Consecutive readers
|
||||
//! form a group; a later writer cannot overtake them, nor can later readers
|
||||
//! overtake a queued writer. This does not limit a current holder's I/O time.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use super::{
|
||||
state::ObjectLockState,
|
||||
types::{LockMode, ObjectKey, ObjectLockRequest},
|
||||
};
|
||||
|
||||
pub(super) fn applies(key: &ObjectKey) -> bool {
|
||||
key.version.is_none() && key.object.as_ref() == rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct PublicationQueue {
|
||||
entries: Mutex<VecDeque<(Arc<()>, LockMode)>>,
|
||||
pub(super) changed: Notify,
|
||||
}
|
||||
|
||||
pub(super) struct PublicationTicket {
|
||||
state: Arc<ObjectLockState>,
|
||||
pub(super) queue: Arc<PublicationQueue>,
|
||||
token: Arc<()>,
|
||||
mode: LockMode,
|
||||
}
|
||||
|
||||
impl PublicationTicket {
|
||||
// Called while the shard map is locked. The waiter counter pins the map
|
||||
// entry until ticket destruction, including cancellation between polls.
|
||||
pub(super) fn new(state: Arc<ObjectLockState>, mode: LockMode) -> Option<Self> {
|
||||
let counted = match mode {
|
||||
LockMode::Shared => state.atomic_state.inc_readers_waiting(),
|
||||
LockMode::Exclusive => state.atomic_state.inc_writers_waiting(),
|
||||
};
|
||||
if !counted {
|
||||
return None;
|
||||
}
|
||||
let queue = state
|
||||
.publication
|
||||
.get_or_init(|| Arc::new(PublicationQueue::default()))
|
||||
.clone();
|
||||
let token = Arc::new(());
|
||||
queue.entries.lock().push_back((token.clone(), mode));
|
||||
Some(Self {
|
||||
state,
|
||||
queue,
|
||||
token,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn try_acquire(&self, request: &ObjectLockRequest) -> bool {
|
||||
// Lock order: shard map -> queue -> owner metadata. Release never
|
||||
// locks the queue; ticket destruction only locks the queue.
|
||||
let entries = self.queue.entries.lock();
|
||||
let mut found = false;
|
||||
for (token, mode) in entries.iter() {
|
||||
if Arc::ptr_eq(token, &self.token) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if self.mode == LockMode::Exclusive || *mode == LockMode::Exclusive {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false;
|
||||
}
|
||||
match self.mode {
|
||||
LockMode::Shared => self.state.try_acquire_admitted_shared(&request.owner, request.lock_timeout),
|
||||
LockMode::Exclusive => self.state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PublicationTicket {
|
||||
fn drop(&mut self) {
|
||||
{
|
||||
let mut entries = self.queue.entries.lock();
|
||||
if let Some(index) = entries.iter().position(|(token, _)| Arc::ptr_eq(token, &self.token)) {
|
||||
entries.remove(index);
|
||||
}
|
||||
}
|
||||
match self.mode {
|
||||
LockMode::Shared => self.state.atomic_state.dec_readers_waiting(),
|
||||
LockMode::Exclusive => self.state.atomic_state.dec_writers_waiting(),
|
||||
}
|
||||
self.queue.changed.notify_waiters();
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::{task::JoinHandle, time::timeout};
|
||||
|
||||
fn manager() -> Arc<FastObjectLockManager> {
|
||||
Arc::new(FastObjectLockManager::with_config(LockConfig {
|
||||
shard_count: 1,
|
||||
max_idle_time: Duration::ZERO,
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
fn key() -> ObjectKey {
|
||||
ObjectKey::new("publication-bucket", rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
}
|
||||
|
||||
fn queued(manager: &Arc<FastObjectLockManager>, owner: &str, mode: LockMode) -> JoinHandle<FastLockGuard> {
|
||||
let manager = manager.clone();
|
||||
let owner = owner.to_owned();
|
||||
tokio::spawn(async move {
|
||||
let mut request = ObjectLockRequest::new_read(key(), owner);
|
||||
request.mode = mode;
|
||||
manager
|
||||
.acquire_lock(request.with_acquire_timeout(Duration::from_secs(5)))
|
||||
.await
|
||||
.expect("queued publication lock")
|
||||
})
|
||||
}
|
||||
|
||||
async fn waiters(manager: &FastObjectLockManager, count: u64) {
|
||||
timeout(Duration::from_secs(3), async {
|
||||
while manager.current_lock_counts().1 != count {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("waiter admission must be observable");
|
||||
}
|
||||
|
||||
async fn acquired(task: JoinHandle<FastLockGuard>) -> FastLockGuard {
|
||||
timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.expect("queue must progress")
|
||||
.expect("acquire task")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_queue_serves_read_groups_and_writers_in_arrival_order() {
|
||||
let manager = manager();
|
||||
let holder = manager.acquire_write_lock(key(), "holder").await.expect("initial holder");
|
||||
let w1 = queued(&manager, "w1", LockMode::Exclusive);
|
||||
waiters(&manager, 1).await;
|
||||
let r1 = queued(&manager, "r1", LockMode::Shared);
|
||||
waiters(&manager, 2).await;
|
||||
let r2 = queued(&manager, "r2", LockMode::Shared);
|
||||
waiters(&manager, 3).await;
|
||||
let w2 = queued(&manager, "w2", LockMode::Exclusive);
|
||||
waiters(&manager, 4).await;
|
||||
let r3 = queued(&manager, "r3", LockMode::Shared);
|
||||
waiters(&manager, 5).await;
|
||||
assert_eq!(manager.cleanup_expired_traditional().await, 0, "queued state must stay mapped");
|
||||
drop(holder);
|
||||
assert_eq!(
|
||||
manager.cleanup_expired_traditional().await,
|
||||
0,
|
||||
"an unlocked state with queued tickets must stay mapped"
|
||||
);
|
||||
let w1 = acquired(w1).await;
|
||||
assert!(!r1.is_finished() && !r2.is_finished() && !w2.is_finished() && !r3.is_finished());
|
||||
drop(w1);
|
||||
let r1 = acquired(r1).await;
|
||||
let r2 = acquired(r2).await;
|
||||
assert!(!w2.is_finished() && !r3.is_finished(), "later requests cannot pass the reader group");
|
||||
drop(r1);
|
||||
assert!(!w2.is_finished(), "the second reader still holds the fence");
|
||||
drop(r2);
|
||||
let w2 = acquired(w2).await;
|
||||
assert!(!r3.is_finished(), "a later reader cannot starve the queued writer");
|
||||
drop(w2);
|
||||
drop(acquired(r3).await);
|
||||
assert_eq!(manager.current_lock_counts(), (0, 0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_queue_cancellation_and_timeout_remove_their_turns() {
|
||||
let manager = manager();
|
||||
let holder = manager.acquire_write_lock(key(), "holder").await.expect("initial holder");
|
||||
let cancelled = queued(&manager, "cancelled", LockMode::Exclusive);
|
||||
waiters(&manager, 1).await;
|
||||
let expiring = tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
async move {
|
||||
manager
|
||||
.acquire_lock(ObjectLockRequest::new_write(key(), "expires").with_acquire_timeout(Duration::from_millis(500)))
|
||||
.await
|
||||
}
|
||||
});
|
||||
waiters(&manager, 2).await;
|
||||
let reader = queued(&manager, "survivor", LockMode::Shared);
|
||||
waiters(&manager, 3).await;
|
||||
cancelled.abort();
|
||||
assert!(cancelled.await.expect_err("cancel waiter").is_cancelled());
|
||||
assert!(matches!(expiring.await.expect("timeout task"), Err(LockResult::Timeout)));
|
||||
waiters(&manager, 1).await;
|
||||
assert_eq!(manager.cleanup_expired_traditional().await, 0);
|
||||
drop(holder);
|
||||
drop(acquired(reader).await);
|
||||
assert_eq!(manager.current_lock_counts(), (0, 0));
|
||||
timeout(Duration::from_secs(3), async {
|
||||
while manager.cleanup_expired_traditional().await == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("an empty queue must allow map cleanup");
|
||||
// An empty queue neither keeps a stale turn nor prevents subsequent writers.
|
||||
drop(manager.acquire_write_lock(key(), "next").await.expect("queue recovered"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_queue_batch_fast_path_cannot_bypass_a_reader() {
|
||||
for all_or_nothing in [false, true] {
|
||||
let manager = manager();
|
||||
let holder = manager.acquire_write_lock(key(), "holder").await.expect("initial holder");
|
||||
let reader = queued(&manager, "queued-reader", LockMode::Shared);
|
||||
waiters(&manager, 1).await;
|
||||
drop(holder);
|
||||
// This future can try the batch fast path before the reader runs again.
|
||||
let batch = manager.acquire_locks_batch(
|
||||
BatchLockRequest::new("batch")
|
||||
.add_write_lock(key())
|
||||
.with_all_or_nothing(all_or_nothing),
|
||||
);
|
||||
tokio::pin!(batch);
|
||||
let reader = tokio::select! {
|
||||
biased;
|
||||
_ = &mut batch => panic!("batch overtook an already queued reader"),
|
||||
reader = acquired(reader) => reader,
|
||||
};
|
||||
drop(reader);
|
||||
let batch = timeout(Duration::from_secs(3), batch)
|
||||
.await
|
||||
.expect("batch progresses after reader");
|
||||
assert!(batch.all_acquired);
|
||||
drop(batch);
|
||||
assert_eq!(manager.current_lock_counts(), (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_queue_is_scoped_to_the_unversioned_bucket_fence() {
|
||||
assert!(publication::applies(&key()));
|
||||
assert!(!publication::applies(&ObjectKey::new("publication-bucket", "plain-object")));
|
||||
assert!(!publication::applies(&ObjectKey::with_version(
|
||||
"publication-bucket",
|
||||
rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH,
|
||||
"version"
|
||||
)));
|
||||
}
|
||||
|
||||
async fn cluster_waiters(managers: &[Arc<FastObjectLockManager>], minimum: u64) {
|
||||
timeout(Duration::from_secs(3), async {
|
||||
while !managers.iter().all(|manager| manager.current_lock_counts().1 >= minimum) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all real local peers registered the waiter");
|
||||
}
|
||||
|
||||
fn cluster_queued(lock: &Arc<crate::NamespaceLock>, owner: &str, kind: crate::LockType) -> JoinHandle<crate::NamespaceLockGuard> {
|
||||
let lock = lock.clone();
|
||||
let owner = owner.to_owned();
|
||||
tokio::spawn(async move {
|
||||
lock.acquire_guard(
|
||||
&crate::LockRequest::new(key(), kind, owner)
|
||||
.with_acquire_timeout(Duration::from_secs(5))
|
||||
.with_ttl(Duration::from_secs(30)),
|
||||
)
|
||||
.await
|
||||
.expect("distributed acquire")
|
||||
.expect("distributed quorum")
|
||||
})
|
||||
}
|
||||
|
||||
async fn cluster_acquired(task: JoinHandle<crate::NamespaceLockGuard>) -> crate::NamespaceLockGuard {
|
||||
timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.expect("quorum must progress")
|
||||
.expect("cluster task")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_queue_distributed_quorum_preserves_bidirectional_progress() {
|
||||
let managers = (0..3).map(|_| manager()).collect::<Vec<_>>();
|
||||
let clients = managers
|
||||
.iter()
|
||||
.map(|manager| {
|
||||
Arc::new(crate::LocalClient::with_manager(Arc::new(crate::GlobalLockManager::Enabled(
|
||||
manager.clone(),
|
||||
)))) as Arc<dyn crate::LockClient>
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let lock = Arc::new(crate::NamespaceLock::with_clients("publication-fairness".to_owned(), clients));
|
||||
// Hold every voter while establishing a deterministic arrival order.
|
||||
// A distributed holder may return at quorum and release its spare vote.
|
||||
let mut holders = Vec::new();
|
||||
for manager in &managers {
|
||||
holders.push(manager.acquire_write_lock(key(), "holder").await.expect("hold voter"));
|
||||
}
|
||||
let w1 = cluster_queued(&lock, "w1", crate::LockType::Exclusive);
|
||||
cluster_waiters(&managers, 1).await;
|
||||
let r1 = cluster_queued(&lock, "r1", crate::LockType::Shared);
|
||||
cluster_waiters(&managers, 2).await;
|
||||
let w2 = cluster_queued(&lock, "w2", crate::LockType::Exclusive);
|
||||
cluster_waiters(&managers, 3).await;
|
||||
let r2 = cluster_queued(&lock, "r2", crate::LockType::Shared);
|
||||
cluster_waiters(&managers, 4).await;
|
||||
drop(holders);
|
||||
let w1 = cluster_acquired(w1).await;
|
||||
assert!(!r1.is_finished() && !w2.is_finished() && !r2.is_finished());
|
||||
drop(w1);
|
||||
let r1 = cluster_acquired(r1).await;
|
||||
assert!(!w2.is_finished() && !r2.is_finished());
|
||||
drop(r1);
|
||||
let w2 = cluster_acquired(w2).await;
|
||||
assert!(!r2.is_finished());
|
||||
drop(w2);
|
||||
drop(cluster_acquired(r2).await);
|
||||
timeout(Duration::from_secs(3), async {
|
||||
while managers.iter().any(|manager| manager.current_lock_counts() != (0, 0)) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all quorum guards and pending cleanup must release");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_queue_distributed_cancel_and_timeout_leave_no_orphan_turns() {
|
||||
let managers = (0..3).map(|_| manager()).collect::<Vec<_>>();
|
||||
let clients = managers
|
||||
.iter()
|
||||
.map(|manager| {
|
||||
Arc::new(crate::LocalClient::with_manager(Arc::new(crate::GlobalLockManager::Enabled(
|
||||
manager.clone(),
|
||||
)))) as Arc<dyn crate::LockClient>
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let lock = Arc::new(crate::NamespaceLock::with_clients("publication-cancellation".to_owned(), clients));
|
||||
let mut holders = Vec::new();
|
||||
for manager in &managers {
|
||||
holders.push(manager.acquire_write_lock(key(), "holder").await.expect("hold voter"));
|
||||
}
|
||||
let cancelled = cluster_queued(&lock, "cancelled", crate::LockType::Exclusive);
|
||||
cluster_waiters(&managers, 1).await;
|
||||
cancelled.abort();
|
||||
assert!(cancelled.await.expect_err("cancel distributed waiter").is_cancelled());
|
||||
timeout(Duration::from_secs(3), async {
|
||||
while managers.iter().any(|manager| manager.current_lock_counts().1 != 0) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("aborted peer attempts must remove their tickets");
|
||||
let timed_out = lock
|
||||
.acquire_guard(
|
||||
&crate::LockRequest::new(key(), crate::LockType::Exclusive, "timeout")
|
||||
.with_acquire_timeout(Duration::from_millis(100)),
|
||||
)
|
||||
.await
|
||||
.expect("contention is a failed acquire");
|
||||
assert!(timed_out.is_none());
|
||||
timeout(Duration::from_secs(3), async {
|
||||
while managers.iter().any(|manager| manager.current_lock_counts().1 != 0) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out peer attempts must remove their tickets");
|
||||
let reader = cluster_queued(&lock, "next-reader", crate::LockType::Shared);
|
||||
cluster_waiters(&managers, 1).await;
|
||||
let writer = cluster_queued(&lock, "next-writer", crate::LockType::Exclusive);
|
||||
cluster_waiters(&managers, 2).await;
|
||||
drop(holders);
|
||||
let reader = cluster_acquired(reader).await;
|
||||
assert!(!writer.is_finished());
|
||||
drop(reader);
|
||||
drop(cluster_acquired(writer).await);
|
||||
timeout(Duration::from_secs(3), async {
|
||||
while managers.iter().any(|manager| manager.current_lock_counts() != (0, 0)) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("distributed holders and queues must drain");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_queue_does_not_change_ordinary_key_writer_preference() {
|
||||
let manager = manager();
|
||||
let ordinary = ObjectKey::new("publication-bucket", "ordinary-key");
|
||||
let holder = manager
|
||||
.acquire_write_lock(ordinary.clone(), "holder")
|
||||
.await
|
||||
.expect("ordinary holder");
|
||||
let reader = tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
let ordinary = ordinary.clone();
|
||||
async move { manager.acquire_read_lock(ordinary, "reader").await.expect("ordinary reader") }
|
||||
});
|
||||
waiters(&manager, 1).await;
|
||||
let writer = tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
async move { manager.acquire_write_lock(ordinary, "writer").await.expect("ordinary writer") }
|
||||
});
|
||||
waiters(&manager, 2).await;
|
||||
drop(holder);
|
||||
let writer = acquired(writer).await;
|
||||
assert!(!reader.is_finished(), "ordinary keys retain writer preference");
|
||||
drop(writer);
|
||||
drop(acquired(reader).await);
|
||||
assert_eq!(manager.current_lock_counts(), (0, 0));
|
||||
}
|
||||
@@ -21,7 +21,6 @@ use tokio::time::timeout;
|
||||
use crate::fast_lock::{
|
||||
metrics::ShardMetrics,
|
||||
object_pool::ObjectStatePool,
|
||||
publication::{self, PublicationTicket},
|
||||
state::ObjectLockState,
|
||||
types::{LockMode, LockResult, ObjectKey, ObjectLockRequest},
|
||||
};
|
||||
@@ -135,9 +134,6 @@ impl LockShard {
|
||||
/// Acquire lock with fast path optimization
|
||||
pub async fn acquire_lock(&self, request: &ObjectLockRequest) -> Result<(), LockResult> {
|
||||
let start_time = Instant::now();
|
||||
if publication::applies(&request.key) {
|
||||
return self.acquire_publication(request, start_time).await;
|
||||
}
|
||||
|
||||
// Try fast path first
|
||||
if let Some(_state) = self.try_fast_path(request) {
|
||||
@@ -149,50 +145,8 @@ impl LockShard {
|
||||
self.acquire_lock_slow_path(request, start_time).await
|
||||
}
|
||||
|
||||
fn publication_ticket(&self, request: &ObjectLockRequest) -> Option<PublicationTicket> {
|
||||
let mut objects = self.objects.write();
|
||||
let state = objects
|
||||
.entry(request.key.clone())
|
||||
.or_insert_with(|| Arc::new(*self.object_pool.acquire()))
|
||||
.clone();
|
||||
PublicationTicket::new(state, request.mode)
|
||||
}
|
||||
|
||||
async fn acquire_publication(&self, request: &ObjectLockRequest, started: Instant) -> Result<(), LockResult> {
|
||||
let Some(ticket) = self.publication_ticket(request) else {
|
||||
return Err(LockResult::Timeout);
|
||||
};
|
||||
let mut first_attempt = true;
|
||||
loop {
|
||||
// Register before checking so a release/cancellation cannot be lost
|
||||
// between the eligibility check and the notification wait.
|
||||
let notified = ticket.queue.changed.notified();
|
||||
let remaining = request.acquire_timeout.saturating_sub(started.elapsed());
|
||||
if !first_attempt && remaining.is_zero() {
|
||||
self.metrics.record_timeout();
|
||||
return Err(LockResult::Timeout);
|
||||
}
|
||||
if ticket.try_acquire(request) {
|
||||
self.metrics.record_slow_path_success();
|
||||
return Ok(());
|
||||
}
|
||||
first_attempt = false;
|
||||
if remaining.is_zero() || timeout(remaining, notified).await.is_err() {
|
||||
self.metrics.record_timeout();
|
||||
return Err(LockResult::Timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try fast path only (without fallback to slow path)
|
||||
pub fn try_fast_path_only(&self, request: &ObjectLockRequest) -> bool {
|
||||
if publication::applies(&request.key) {
|
||||
// Batch acquisition must join the same queue, even for a no-wait
|
||||
// attempt. Dropping a failed ticket does not consume a queue turn.
|
||||
return self
|
||||
.publication_ticket(request)
|
||||
.is_some_and(|ticket| ticket.try_acquire(request));
|
||||
}
|
||||
// Early check to avoid unnecessary lock contention
|
||||
if let Some(state) = self.objects.read().get(&request.key)
|
||||
&& !state.atomic_state.is_fast_path_available(request.mode)
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
@@ -78,21 +78,13 @@ impl AtomicLockState {
|
||||
|
||||
/// Try to acquire shared lock (fast path)
|
||||
pub fn try_acquire_shared(&self) -> bool {
|
||||
self.try_acquire_shared_with_mask(NO_WRITER_AND_NO_WAITING_WRITERS)
|
||||
}
|
||||
|
||||
// Publication admission already orders readers against waiting writers.
|
||||
fn try_acquire_admitted_shared(&self) -> bool {
|
||||
self.try_acquire_shared_with_mask(WRITER_FLAG_MASK)
|
||||
}
|
||||
|
||||
fn try_acquire_shared_with_mask(&self, blocked_mask: u64) -> bool {
|
||||
self.update_access_time();
|
||||
|
||||
loop {
|
||||
let current = self.state.load(Ordering::Acquire);
|
||||
|
||||
if (current & blocked_mask) != 0 {
|
||||
// Fast path check - cannot acquire if there's a writer or writers waiting
|
||||
if (current & NO_WRITER_AND_NO_WAITING_WRITERS) != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -342,7 +334,6 @@ pub struct ObjectLockState {
|
||||
// First cache line: Most frequently accessed data
|
||||
/// Atomic state for fast operations
|
||||
pub atomic_state: AtomicLockState,
|
||||
pub(super) publication: OnceLock<Arc<super::publication::PublicationQueue>>,
|
||||
|
||||
// Second cache line: Notification mechanisms
|
||||
/// Notification for readers (traditional)
|
||||
@@ -386,7 +377,6 @@ impl ObjectLockState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
atomic_state: AtomicLockState::new(),
|
||||
publication: OnceLock::new(),
|
||||
read_notify: Notify::new(),
|
||||
write_notify: Notify::new(),
|
||||
optimized_notify: OptimizedNotify::new(),
|
||||
@@ -402,19 +392,6 @@ impl ObjectLockState {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.record_shared_owner(owner, lock_timeout);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn try_acquire_admitted_shared(&self, owner: &Arc<str>, lock_timeout: Duration) -> bool {
|
||||
if !self.atomic_state.try_acquire_admitted_shared() {
|
||||
return false;
|
||||
}
|
||||
self.record_shared_owner(owner, lock_timeout);
|
||||
true
|
||||
}
|
||||
|
||||
fn record_shared_owner(&self, owner: &Arc<str>, lock_timeout: Duration) {
|
||||
self.atomic_state.update_access_time();
|
||||
let mut shared = self.shared_owners.write();
|
||||
if let Some(entry) = shared.iter_mut().find(|entry| entry.owner.as_ref() == owner.as_ref()) {
|
||||
@@ -429,6 +406,7 @@ impl ObjectLockState {
|
||||
lock_timeout,
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Try fast path exclusive lock acquisition
|
||||
@@ -459,9 +437,6 @@ impl ObjectLockState {
|
||||
Some(shared.remove(pos))
|
||||
};
|
||||
if self.atomic_state.release_shared() {
|
||||
if let Some(queue) = self.publication.get() {
|
||||
queue.changed.notify_waiters();
|
||||
}
|
||||
if shared.is_empty() {
|
||||
drop(shared);
|
||||
self.optimized_notify.notify_writer();
|
||||
@@ -505,9 +480,6 @@ impl ObjectLockState {
|
||||
if self.atomic_state.release_exclusive() {
|
||||
*current = None;
|
||||
drop(current);
|
||||
if let Some(queue) = self.publication.get() {
|
||||
queue.changed.notify_waiters();
|
||||
}
|
||||
// Notify waiters using optimized system - prefer writers over readers
|
||||
if self
|
||||
.atomic_state
|
||||
|
||||
@@ -881,7 +881,7 @@ pub fn parse_replicate_decision(_bucket: &str, s: &str) -> std::io::Result<Repli
|
||||
/// writers may use different textual representations. `invalid` preserves the
|
||||
/// distinction between truly absent legacy metadata and corrupt/conflicting
|
||||
/// compatibility aliases.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReplicationGenerationSnapshot {
|
||||
pub timestamp: Option<String>,
|
||||
pub mutation_id: Option<String>,
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
|
||||
use futures_util::ready;
|
||||
use http::HeaderMap;
|
||||
use std::io::{Error as IoError, ErrorKind as IoErrorKind, Read};
|
||||
use std::io::{Cursor, Error as IoError, ErrorKind as IoErrorKind, Read};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::BufReader;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use crate::{
|
||||
api_error_response::err_invalid_argument,
|
||||
api_get_options::GetObjectOptions,
|
||||
transition_api::{
|
||||
ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, collect_response_body, response_body_reader,
|
||||
to_object_info_for_provider,
|
||||
ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, collect_response_body, to_object_info_for_provider,
|
||||
},
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
@@ -86,11 +86,6 @@ impl TransitionClient {
|
||||
))
|
||||
}
|
||||
|
||||
/// Open a remote object without collecting its response body.
|
||||
///
|
||||
/// Request and response-header errors are returned here. Body transport,
|
||||
/// idle-timeout and range-limit errors are returned when the reader is read.
|
||||
/// The reader owns the response; dropping it releases an unfinished body.
|
||||
pub async fn get_object_inner(
|
||||
&self,
|
||||
bucket_name: &str,
|
||||
@@ -125,8 +120,12 @@ impl TransitionClient {
|
||||
|
||||
let h = resp.headers().clone();
|
||||
|
||||
let reader = response_body_reader(resp.into_body(), max_response_bytes, self.timeouts.response_body_idle_timeout);
|
||||
Ok((object_stat, h, reader))
|
||||
let body_vec = if let Some(limit) = max_response_bytes {
|
||||
self.collect_response_body(resp.into_body(), limit).await?
|
||||
} else {
|
||||
self.collect_response_body_unbounded(resp.into_body()).await?
|
||||
};
|
||||
Ok((object_stat, h, BufReader::new(Cursor::new(body_vec))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,13 +269,9 @@ mod bounded_response_tests {
|
||||
let mut opts = GetObjectOptions::default();
|
||||
opts.set_range(0, 6).expect("the probe range should be valid");
|
||||
|
||||
let (_, _, mut reader) = client
|
||||
let err = client
|
||||
.get_object_inner("bucket", "probe", &opts)
|
||||
.await
|
||||
.expect("the response headers should produce a reader");
|
||||
let err = reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("an eight-byte response must exceed the seven-byte range limit");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
@@ -396,13 +391,9 @@ mod bounded_response_tests {
|
||||
let mut opts = GetObjectOptions::default();
|
||||
opts.set_range(0, 6).expect("the probe range should be valid");
|
||||
|
||||
let (_, _, mut reader) = client
|
||||
let err = client
|
||||
.get_object_inner("bucket", "probe", &opts)
|
||||
.await
|
||||
.expect("the response headers should produce a reader");
|
||||
let err = reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("body stalls after partial progress must be bounded");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
|
||||
|
||||
@@ -68,6 +68,7 @@ use s3s::dto::ReplicationStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::error::Error as StdError;
|
||||
use std::io::Cursor;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
@@ -78,6 +79,7 @@ use std::{
|
||||
};
|
||||
use time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
use tracing::{error, trace, warn};
|
||||
use url::{Url, form_urlencoded};
|
||||
@@ -154,7 +156,7 @@ where
|
||||
|
||||
async fn collect_response_body_inner<B>(
|
||||
body: B,
|
||||
limit: usize,
|
||||
limit: Option<usize>,
|
||||
idle_timeout: Option<StdDuration>,
|
||||
) -> Result<Vec<u8>, std::io::Error>
|
||||
where
|
||||
@@ -167,7 +169,7 @@ where
|
||||
let Some(new_len) = body_vec.len().checked_add(data.len()) else {
|
||||
return Err(response_body_exceeds_limit_error());
|
||||
};
|
||||
if new_len > limit {
|
||||
if limit.is_some_and(|limit| new_len > limit) {
|
||||
return Err(response_body_exceeds_limit_error());
|
||||
}
|
||||
body_vec.extend_from_slice(&data);
|
||||
@@ -180,22 +182,7 @@ where
|
||||
B: Body<Data = Bytes>,
|
||||
B::Error: Into<Box<dyn StdError + Send + Sync>>,
|
||||
{
|
||||
collect_response_body_inner(body, limit, None).await
|
||||
}
|
||||
|
||||
/// Keep the remote body with its reader; idle and range errors surface while reading.
|
||||
pub(crate) fn response_body_reader(body: Incoming, limit: Option<usize>, idle_timeout: StdDuration) -> ReadCloser {
|
||||
let stream = futures::stream::try_unfold((Box::pin(body), 0_usize), move |(mut body, received)| async move {
|
||||
let Some(data) = next_response_body_data(body.as_mut(), Some(idle_timeout)).await? else {
|
||||
return Ok::<_, std::io::Error>(None);
|
||||
};
|
||||
let received = received
|
||||
.checked_add(data.len())
|
||||
.filter(|size| limit.is_none_or(|limit| *size <= limit))
|
||||
.ok_or_else(response_body_exceeds_limit_error)?;
|
||||
Ok(Some((data, (body, received))))
|
||||
});
|
||||
ReadCloser::new(tokio_util::io::StreamReader::new(Box::pin(stream)))
|
||||
collect_response_body_inner(body, Some(limit), None).await
|
||||
}
|
||||
|
||||
const C_UNKNOWN: i32 = -1;
|
||||
@@ -230,9 +217,9 @@ fn signer_error_to_io_error(scope: &str, error: rustfs_signer::SignV4Error) -> s
|
||||
signer_error::signer_error_to_io_error(scope, error)
|
||||
}
|
||||
|
||||
/// Streaming object body shared by the client and storage engine. The concrete
|
||||
/// reader (e.g. ecstore's `GetObjectReader` or a remote response body) stays
|
||||
/// behind this boxed `AsyncRead`.
|
||||
/// Streaming object body handed to the client by the storage engine. The
|
||||
/// client only ever reads it to completion, so the engine-side reader type
|
||||
/// (e.g. ecstore's `GetObjectReader`) stays behind this boxed `AsyncRead`.
|
||||
pub struct ObjectReader(Box<dyn AsyncRead + Send + Sync + Unpin>);
|
||||
|
||||
impl ObjectReader {
|
||||
@@ -268,8 +255,7 @@ pub enum ReaderImpl {
|
||||
ObjectBody(ObjectReader),
|
||||
}
|
||||
|
||||
/// Remote object reader. Dropping it releases the response body without collecting it.
|
||||
pub type ReadCloser = ObjectReader;
|
||||
pub type ReadCloser = BufReader<Cursor<Vec<u8>>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TransitionClient {
|
||||
@@ -828,7 +814,15 @@ impl TransitionClient {
|
||||
B: Body<Data = Bytes>,
|
||||
B::Error: Into<Box<dyn StdError + Send + Sync>>,
|
||||
{
|
||||
collect_response_body_inner(body, limit, Some(self.timeouts.response_body_idle_timeout)).await
|
||||
collect_response_body_inner(body, Some(limit), Some(self.timeouts.response_body_idle_timeout)).await
|
||||
}
|
||||
|
||||
pub async fn collect_response_body_unbounded<B>(&self, body: B) -> Result<Vec<u8>, std::io::Error>
|
||||
where
|
||||
B: Body<Data = Bytes>,
|
||||
B::Error: Into<Box<dyn StdError + Send + Sync>>,
|
||||
{
|
||||
collect_response_body_inner(body, None, Some(self.timeouts.response_body_idle_timeout)).await
|
||||
}
|
||||
|
||||
async fn new_request(
|
||||
@@ -1782,7 +1776,7 @@ mod tests {
|
||||
|
||||
let err = tokio::time::timeout(
|
||||
StdDuration::from_millis(200),
|
||||
collect_response_body_inner(body, 1, Some(StdDuration::from_millis(50))),
|
||||
collect_response_body_inner(body, Some(1), Some(StdDuration::from_millis(50))),
|
||||
)
|
||||
.await
|
||||
.expect("the collector should enforce its own body idle timeout")
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_s3_client::{
|
||||
api_get_options::GetObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, TransitionClient, TransitionClientTimeouts},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
async fn run_case(ranged: bool) -> bool {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind fixture");
|
||||
let endpoint = listener.local_addr().expect("local endpoint").to_string();
|
||||
let (headers_sent, headers_ready) = tokio::sync::oneshot::channel();
|
||||
let (release, released) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("GET connection");
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let n = socket.read(&mut buffer).await.expect("GET headers");
|
||||
assert_ne!(n, 0);
|
||||
request.extend_from_slice(&buffer[..n]);
|
||||
if request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8(request).expect("ASCII HTTP request");
|
||||
assert!(request.starts_with("GET /bucket/object "), "{request}");
|
||||
assert_eq!(request.to_ascii_lowercase().contains("\r\nrange: bytes=0-7\r\n"), ranged, "{request}");
|
||||
let status = if ranged { "206 Partial Content" } else { "200 OK" };
|
||||
let range_header = if ranged { "Content-Range: bytes 0-7/8\r\n" } else { "" };
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Length: 8\r\n{range_header}Content-Type: application/octet-stream\r\nETag: \"fixture\"\r\nConnection: close\r\n\r\nRust"
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.expect("first body bytes");
|
||||
headers_sent.send(()).expect("signal response head");
|
||||
released.await.expect("release remaining body");
|
||||
socket.write_all(b"FS!!").await.expect("last body bytes");
|
||||
});
|
||||
let options = Options {
|
||||
creds: Credentials::new(Static(Value {
|
||||
access_key_id: "access-key".into(),
|
||||
secret_access_key: "secret-key".into(),
|
||||
signer_type: SignatureType::SignatureV4,
|
||||
..Default::default()
|
||||
})),
|
||||
region: "us-east-1".into(),
|
||||
bucket_lookup: BucketLookupType::BucketLookupAuto,
|
||||
max_retries: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let client = TransitionClient::new_with_timeouts(&endpoint, options, "s3", TransitionClientTimeouts::default())
|
||||
.await
|
||||
.expect("production client");
|
||||
let client = rustfs_s3_client::transition_api::TransitionCore(std::sync::Arc::new(client));
|
||||
let mut opts = GetObjectOptions::default();
|
||||
if ranged {
|
||||
opts.set_range(0, 7).expect("closed production range");
|
||||
}
|
||||
let get = client.get_object("bucket", "object", &opts);
|
||||
tokio::pin!(get);
|
||||
let probe = async {
|
||||
tokio::select! {
|
||||
result = &mut get => Some(result),
|
||||
_ = headers_ready => None,
|
||||
}
|
||||
}
|
||||
.await;
|
||||
let before_eof = match probe {
|
||||
Some(result) => Ok(result),
|
||||
None => tokio::time::timeout(Duration::from_secs(2), &mut get).await,
|
||||
};
|
||||
let returned_before_body_end = before_eof.is_ok();
|
||||
let mut body = Vec::new();
|
||||
let mut reader = match before_eof {
|
||||
Ok(result) => {
|
||||
let (_, _, mut reader) = result.expect("streaming GET");
|
||||
let mut prefix = [0; 4];
|
||||
tokio::time::timeout(Duration::from_secs(2), reader.read_exact(&mut prefix))
|
||||
.await
|
||||
.expect("first bytes must arrive before the remote body ends")
|
||||
.expect("first body bytes");
|
||||
body.extend_from_slice(&prefix);
|
||||
release.send(()).expect("release fixture");
|
||||
reader
|
||||
}
|
||||
Err(_) => {
|
||||
release.send(()).expect("release fixture");
|
||||
let (_, _, reader) = get.await.expect("buffered GET after full body");
|
||||
reader
|
||||
}
|
||||
};
|
||||
reader.read_to_end(&mut body).await.expect("response bytes");
|
||||
assert_eq!(body, b"RustFS!!");
|
||||
server.await.expect("fixture server");
|
||||
returned_before_body_end
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_object_returns_reader_before_remote_response_ends() {
|
||||
let full = run_case(false).await;
|
||||
let closed_range = run_case(true).await;
|
||||
assert!(full && closed_range, "tier GET must stream: full={full}, closed_range={closed_range}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_get_object_reader_closes_the_unfinished_response() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind fixture");
|
||||
let endpoint = listener.local_addr().expect("local endpoint").to_string();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("GET connection");
|
||||
let mut request = Vec::new();
|
||||
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||||
let mut buffer = [0; 1024];
|
||||
let size = socket.read(&mut buffer).await.expect("GET headers");
|
||||
assert_ne!(size, 0);
|
||||
request.extend_from_slice(&buffer[..size]);
|
||||
}
|
||||
socket
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nRust")
|
||||
.await
|
||||
.expect("partial response");
|
||||
let closed = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut [0; 1]))
|
||||
.await
|
||||
.expect("dropping the reader must release the remote connection");
|
||||
match closed {
|
||||
Ok(size) => assert_eq!(size, 0, "a canceled body must not send another request"),
|
||||
Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::ConnectionReset),
|
||||
}
|
||||
});
|
||||
let options = Options {
|
||||
creds: Credentials::new(Static(Value {
|
||||
access_key_id: "access-key".into(),
|
||||
secret_access_key: "secret-key".into(),
|
||||
signer_type: SignatureType::SignatureV4,
|
||||
..Default::default()
|
||||
})),
|
||||
region: "us-east-1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let client = TransitionClient::new_with_timeouts(&endpoint, options, "s3", TransitionClientTimeouts::default())
|
||||
.await
|
||||
.expect("production client");
|
||||
let (_, _, reader) = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client.get_object_inner("bucket", "object", &GetObjectOptions::default()),
|
||||
)
|
||||
.await
|
||||
.expect("headers must return before the response completes")
|
||||
.expect("GET reader");
|
||||
drop(reader);
|
||||
server.await.expect("fixture server");
|
||||
}
|
||||
@@ -88,7 +88,7 @@ transform-stream.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rustfs-test-utils = { workspace = true }
|
||||
rustfs-test-utils = { workspace = true, features = ["put-object-commit-barrier"] }
|
||||
serial_test.workspace = true
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -424,7 +424,7 @@ impl EcObjectStore {
|
||||
|
||||
async fn object_reader(&self, range: Option<HTTPRangeSpec>) -> Result<SelectGetObjectReader> {
|
||||
#[cfg(test)]
|
||||
test::pause_before_reader_open(&self.input.bucket, self.reader_open_count.fetch_add(1, Ordering::Relaxed) + 1).await;
|
||||
self.reader_open_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.snapshot(None)
|
||||
.await?
|
||||
.open_reader(range)
|
||||
@@ -1416,6 +1416,7 @@ mod test {
|
||||
use futures::{StreamExt, TryStreamExt, stream};
|
||||
use http::HeaderMap;
|
||||
use parking_lot::Mutex;
|
||||
use rustfs_test_utils::PutObjectCommitBarrier;
|
||||
use s3s::S3ErrorCode;
|
||||
use s3s::dto::{
|
||||
CSVInput, CSVOutput, CompressionType, ExpressionType, FileHeaderInfo, InputSerialization, JSONInput, JSONOutput,
|
||||
@@ -1432,54 +1433,6 @@ mod test {
|
||||
|
||||
use tokio::{io::AsyncReadExt, sync::Semaphore};
|
||||
|
||||
struct ReaderOpenPause {
|
||||
bucket: String,
|
||||
ordinal: usize,
|
||||
reached: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
static READER_OPEN_PAUSE: Mutex<Option<Arc<ReaderOpenPause>>> = Mutex::new(None);
|
||||
|
||||
struct ReaderOpenPauseGuard(Arc<ReaderOpenPause>);
|
||||
|
||||
impl ReaderOpenPauseGuard {
|
||||
fn install(bucket: &str, ordinal: usize) -> Self {
|
||||
let hook = Arc::new(ReaderOpenPause {
|
||||
bucket: bucket.to_owned(),
|
||||
ordinal,
|
||||
reached: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
*READER_OPEN_PAUSE.lock() = Some(Arc::clone(&hook));
|
||||
Self(hook)
|
||||
}
|
||||
async fn wait(&self) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), self.0.reached.notified())
|
||||
.await
|
||||
.expect("query reached next range");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReaderOpenPauseGuard {
|
||||
fn drop(&mut self) {
|
||||
*READER_OPEN_PAUSE.lock() = None;
|
||||
self.0.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn pause_before_reader_open(bucket: &str, ordinal: usize) {
|
||||
let hook = READER_OPEN_PAUSE
|
||||
.lock()
|
||||
.as_ref()
|
||||
.filter(|hook| hook.bucket == bucket && hook.ordinal == ordinal)
|
||||
.cloned();
|
||||
if let Some(hook) = hook {
|
||||
hook.reached.notify_one();
|
||||
hook.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
const JSON_DOCUMENT_TEST_MEMORY_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1752,7 +1705,7 @@ mod test {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial]
|
||||
async fn legacy_constructor_revalidates_head_snapshot_before_body() {
|
||||
async fn legacy_constructor_reuses_head_snapshot_for_body() {
|
||||
const BUCKET: &str = "s3select-lazy-snapshot-head-body";
|
||||
const OBJECT: &str = "input.csv";
|
||||
const OLD_DATA: &[u8] = b"id,name\n1,old\n";
|
||||
@@ -1774,15 +1727,34 @@ mod test {
|
||||
.expect("HEAD should lazily prepare the snapshot");
|
||||
assert_eq!(head.meta.size, u64::try_from(OLD_DATA.len()).expect("fixture length should fit in u64"));
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), env.put_object_bytes(BUCKET, OBJECT, NEW_DATA.to_vec()))
|
||||
.await
|
||||
.expect("HEAD snapshot must not block overwrite");
|
||||
let error = store
|
||||
let commit_barrier = PutObjectCommitBarrier::before_namespace(BUCKET, OBJECT);
|
||||
let writer = tokio::spawn(async move {
|
||||
env.put_object_bytes(BUCKET, OBJECT, NEW_DATA.to_vec()).await;
|
||||
});
|
||||
commit_barrier.wait_until_paused().await;
|
||||
commit_barrier.release_and_wait_until_namespace_pending().await;
|
||||
assert!(!writer.is_finished(), "overwrite must wait for the lazy snapshot");
|
||||
|
||||
let result = store
|
||||
.get_opts(&Path::from(OBJECT), GetOptions::default())
|
||||
.await
|
||||
.expect_err("first body open must revalidate HEAD generation");
|
||||
assert!(error.to_string().contains("object read semantics changed"));
|
||||
.expect("body should reuse the HEAD snapshot");
|
||||
let GetResultPayload::Stream(stream) = result.payload else {
|
||||
panic!("expected streaming snapshot body");
|
||||
};
|
||||
let bytes = stream
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("collect lazy snapshot body")
|
||||
.concat();
|
||||
assert_eq!(bytes, OLD_DATA);
|
||||
assert_eq!(store.reader_open_count.load(Ordering::Relaxed), 1);
|
||||
|
||||
drop(store);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), writer)
|
||||
.await
|
||||
.expect("overwrite should finish after the lazy snapshot is released")
|
||||
.expect("overwrite task should join");
|
||||
assert_eq!(read_current_object(BUCKET, OBJECT).await, NEW_DATA);
|
||||
}
|
||||
|
||||
@@ -2117,23 +2089,39 @@ mod test {
|
||||
selected_start,
|
||||
selected_start,
|
||||
);
|
||||
let store = Arc::new(store);
|
||||
let pause = ReaderOpenPauseGuard::install(BUCKET, 2);
|
||||
let reading = tokio::spawn({
|
||||
let store = Arc::clone(&store);
|
||||
async move { store.get_opts(&Path::from(OBJECT), GetOptions::default()).await }
|
||||
let commit_barrier = PutObjectCommitBarrier::before_namespace(BUCKET, OBJECT);
|
||||
let writer = tokio::spawn(async move {
|
||||
env.put_object_bytes(BUCKET, OBJECT, NEW_DATA.to_vec()).await;
|
||||
});
|
||||
pause.wait().await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), env.put_object_bytes(BUCKET, OBJECT, NEW_DATA.to_vec()))
|
||||
commit_barrier.wait_until_paused().await;
|
||||
commit_barrier.release_and_wait_until_namespace_pending().await;
|
||||
assert!(
|
||||
!writer.is_finished(),
|
||||
"overwrite must remain blocked while the SelectObjectContent snapshot is alive"
|
||||
);
|
||||
|
||||
let result = store
|
||||
.get_opts(&Path::from(OBJECT), GetOptions::default())
|
||||
.await
|
||||
.expect("writer must complete between Select range opens");
|
||||
drop(pause);
|
||||
let error = match reading.await.expect("query task") {
|
||||
Ok(_) => panic!("query must not combine old context with replacement body"),
|
||||
Err(error) => error,
|
||||
.expect("read CSV header and body from one snapshot");
|
||||
let GetResultPayload::Stream(stream) = result.payload else {
|
||||
panic!("expected streaming CSV header/body payload");
|
||||
};
|
||||
assert!(error.to_string().contains("object read semantics changed"));
|
||||
let bytes = stream
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("collect CSV header/body snapshot")
|
||||
.concat();
|
||||
|
||||
assert_eq!(bytes, b"old_header,value\nold_body,1\n");
|
||||
assert_eq!(store.reader_open_count.load(Ordering::Relaxed), 2);
|
||||
assert!(!writer.is_finished(), "overwrite must remain blocked after both snapshot readers finish");
|
||||
|
||||
drop(store);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), writer)
|
||||
.await
|
||||
.expect("overwrite should finish after the snapshot is released")
|
||||
.expect("overwrite task should join");
|
||||
assert_eq!(read_current_object(BUCKET, OBJECT).await, NEW_DATA);
|
||||
}
|
||||
|
||||
@@ -2155,23 +2143,42 @@ mod test {
|
||||
|
||||
let snapshot = prepare_test_snapshot(BUCKET, OBJECT).await;
|
||||
let store = scan_range_csv_store(BUCKET, OBJECT, snapshot, "aa", None, 4, 5);
|
||||
let store = Arc::new(store);
|
||||
let pause = ReaderOpenPauseGuard::install(BUCKET, 2);
|
||||
let reading = tokio::spawn({
|
||||
let store = Arc::clone(&store);
|
||||
async move { store.get_opts(&Path::from(OBJECT), GetOptions::default()).await }
|
||||
let commit_barrier = PutObjectCommitBarrier::before_namespace(BUCKET, OBJECT);
|
||||
let writer = tokio::spawn(async move {
|
||||
env.put_object_bytes(BUCKET, OBJECT, NEW_DATA.to_vec()).await;
|
||||
});
|
||||
pause.wait().await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), env.put_object_bytes(BUCKET, OBJECT, NEW_DATA.to_vec()))
|
||||
commit_barrier.wait_until_paused().await;
|
||||
commit_barrier.release_and_wait_until_namespace_pending().await;
|
||||
assert!(
|
||||
!writer.is_finished(),
|
||||
"overwrite must remain blocked while the SelectObjectContent snapshot is alive"
|
||||
);
|
||||
|
||||
let result = store
|
||||
.get_opts(&Path::from(OBJECT), GetOptions::default())
|
||||
.await
|
||||
.expect("writer must complete between Select range opens");
|
||||
drop(pause);
|
||||
let error = match reading.await.expect("query task") {
|
||||
Ok(_) => panic!("query must not combine old context with replacement body"),
|
||||
Err(error) => error,
|
||||
.expect("read ScanRange context and main body from one snapshot");
|
||||
let GetResultPayload::Stream(stream) = result.payload else {
|
||||
panic!("expected streaming ScanRange context payload");
|
||||
};
|
||||
assert!(error.to_string().contains("object read semantics changed"));
|
||||
let bytes = stream
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("collect ScanRange context snapshot")
|
||||
.concat();
|
||||
|
||||
assert_eq!(bytes, b"a222\r\n");
|
||||
assert_eq!(store.reader_open_count.load(Ordering::Relaxed), 2);
|
||||
assert!(
|
||||
!writer.is_finished(),
|
||||
"overwrite must remain blocked after context and main readers finish"
|
||||
);
|
||||
|
||||
drop(store);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), writer)
|
||||
.await
|
||||
.expect("overwrite should finish after the snapshot is released")
|
||||
.expect("overwrite task should join");
|
||||
assert_eq!(read_current_object(BUCKET, OBJECT).await, NEW_DATA);
|
||||
}
|
||||
|
||||
@@ -2461,117 +2468,6 @@ mod test {
|
||||
assert!(err.to_string().contains("ScanRange"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial]
|
||||
async fn parquet_snapshot_rejects_overwrite_between_footer_and_data_ranges() {
|
||||
use datafusion::arrow::{
|
||||
array::StringArray,
|
||||
datatypes::{DataType, Field, Schema},
|
||||
record_batch::RecordBatch,
|
||||
};
|
||||
use datafusion::object_store::ObjectStoreExt as _;
|
||||
use datafusion::parquet::{
|
||||
arrow::{
|
||||
ArrowWriter,
|
||||
arrow_reader::ArrowReaderOptions,
|
||||
async_reader::{AsyncFileReader, ParquetRecordBatchStreamBuilder},
|
||||
},
|
||||
basic::Compression,
|
||||
errors::{ParquetError, Result as ParquetResult},
|
||||
file::{
|
||||
metadata::{ParquetMetaData, ParquetMetaDataReader},
|
||||
properties::WriterProperties,
|
||||
},
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
struct SnapshotParquetReader {
|
||||
store: Arc<EcObjectStore>,
|
||||
path: Path,
|
||||
file_size: u64,
|
||||
}
|
||||
|
||||
impl AsyncFileReader for SnapshotParquetReader {
|
||||
fn get_bytes(&mut self, range: std::ops::Range<u64>) -> BoxFuture<'_, ParquetResult<Bytes>> {
|
||||
Box::pin(async move {
|
||||
self.store
|
||||
.get_range(&self.path, range)
|
||||
.await
|
||||
.map_err(|error| ParquetError::External(Box::new(error)))
|
||||
})
|
||||
}
|
||||
|
||||
fn get_metadata<'a>(
|
||||
&'a mut self,
|
||||
options: Option<&'a ArrowReaderOptions>,
|
||||
) -> BoxFuture<'a, ParquetResult<Arc<ParquetMetaData>>> {
|
||||
Box::pin(async move {
|
||||
let file_size = self.file_size;
|
||||
let metadata = ParquetMetaDataReader::new()
|
||||
.with_arrow_reader_options(options)
|
||||
.load_and_finish(self, file_size)
|
||||
.await?;
|
||||
Ok(Arc::new(metadata))
|
||||
})
|
||||
}
|
||||
}
|
||||
const BUCKET: &str = "select-parquet-range-generation";
|
||||
const OBJECT: &str = "input.parquet";
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Utf8, false)]));
|
||||
let values = (0..6000)
|
||||
.map(|index| format!("{index:08}{}", "x".repeat(248)))
|
||||
.collect::<Vec<_>>();
|
||||
let batch = RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(StringArray::from(values))]).expect("Parquet batch");
|
||||
let props = WriterProperties::builder()
|
||||
.set_dictionary_enabled(false)
|
||||
.set_compression(Compression::UNCOMPRESSED)
|
||||
.build();
|
||||
let mut bytes = Vec::new();
|
||||
let mut writer = ArrowWriter::try_new(&mut bytes, schema, Some(props)).expect("Parquet writer");
|
||||
writer.write(&batch).expect("Parquet rows");
|
||||
writer.close().expect("Parquet footer");
|
||||
assert!(bytes.len() > 1024 * 1024, "fixture requires data outside the footer prefetch");
|
||||
let env = crate::storage_api::select_test_ecstore_env().await;
|
||||
env.make_bucket(BUCKET, false).await;
|
||||
env.put_object_bytes(BUCKET, OBJECT, bytes.clone()).await;
|
||||
let snapshot = prepare_test_snapshot(BUCKET, OBJECT).await;
|
||||
let mut input = (*csv_input(BUCKET, OBJECT)).clone();
|
||||
input.request.input_serialization.csv = None;
|
||||
input.request.input_serialization.parquet = Some(s3s::dto::ParquetInput {});
|
||||
let store = Arc::new(EcObjectStore::new_with_snapshot(Arc::new(input), Arc::clone(&snapshot)).expect("Parquet store"));
|
||||
let (footer_ready, footer_loaded) = tokio::sync::oneshot::channel();
|
||||
let (resume_data, data_ready) = tokio::sync::oneshot::channel();
|
||||
let file_size = snapshot.logical_size();
|
||||
let query_store = store.clone();
|
||||
let query = tokio::spawn(async move {
|
||||
let reader = SnapshotParquetReader {
|
||||
store: query_store,
|
||||
path: Path::from(OBJECT),
|
||||
file_size,
|
||||
};
|
||||
let builder = ParquetRecordBatchStreamBuilder::new(reader).await?;
|
||||
footer_ready.send(()).expect("signal fully decoded footer");
|
||||
data_ready.await.expect("resume data ranges after overwrite");
|
||||
let stream = builder.build()?;
|
||||
stream.try_collect::<Vec<_>>().await
|
||||
});
|
||||
footer_loaded
|
||||
.await
|
||||
.expect("read and decode the original footer before overwriting");
|
||||
// Same bytes, size and ETag still constitute a new generation (new data_dir).
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), env.put_object_bytes(BUCKET, OBJECT, bytes))
|
||||
.await
|
||||
.expect("overwrite cannot wait for Parquet's next range");
|
||||
resume_data.send(()).expect("resume the original query");
|
||||
let error = query
|
||||
.await
|
||||
.expect("Parquet task")
|
||||
.expect_err("footer and row data must not span generations");
|
||||
assert!(error.to_string().contains("object read semantics changed"));
|
||||
assert!(snapshot.ensure_valid().is_err());
|
||||
assert!(store.reader_open_count.load(Ordering::Relaxed) >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_range_conversion_for_parquet_bounded_ranges() {
|
||||
let range = http_range_spec_from_get_range(&GetRange::Bounded(10..20));
|
||||
|
||||
@@ -194,9 +194,7 @@ impl QueryExecutionTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retains admission for queued or running work that can outlive its request.
|
||||
/// Returns `None` once the execution has finished or reached its deadline.
|
||||
pub fn query_guard(&self) -> Option<QueryExecutionGuard> {
|
||||
pub(crate) fn query_guard(&self) -> Option<QueryExecutionGuard> {
|
||||
let query_guard = self.inner.query_guard.lock();
|
||||
if Instant::now() >= self.inner.deadline || self.status() != QueryExecutionStatus::Active {
|
||||
return None;
|
||||
@@ -299,9 +297,6 @@ pub struct SessionCtxFactory {
|
||||
|
||||
pub const DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Maximum inferred fields and explicit projection expressions for S3 Select.
|
||||
pub const MAX_SELECT_COLUMNS: usize = 1024;
|
||||
|
||||
impl Default for SessionCtxFactory {
|
||||
fn default() -> Self {
|
||||
Self::new(false)
|
||||
@@ -441,26 +436,6 @@ impl SessionCtxFactory {
|
||||
};
|
||||
let rt = RuntimeEnvBuilder::new().with_memory_limit(memory_limit_bytes, 1.0).build()?;
|
||||
let config = SessionConfig::new().with_target_partitions(self.target_partitions);
|
||||
// Arrow CSV reserves two usize offsets for every cell outside the
|
||||
// DataFusion memory pool. Bound that reservation to at most 4 MiB and
|
||||
// one sixteenth of the configured query budget, including narrow input.
|
||||
let config = if context.input.request.input_serialization.csv.is_some() {
|
||||
let batch_rows = (memory_limit_bytes / 16 / (MAX_SELECT_COLUMNS * 2 * size_of::<usize>())).clamp(1, 256);
|
||||
config.with_batch_size(batch_rows)
|
||||
} else {
|
||||
config
|
||||
};
|
||||
// Parquet's dictionary views can expand during an explicit SQL cast.
|
||||
// Keep every downstream operator (including FilterExec's internal
|
||||
// coalescer) at one row, and disable the separate coalesce optimizer.
|
||||
let config = if context.input.request.input_serialization.parquet.is_some() {
|
||||
let mut config = config.with_batch_size(1).with_target_partitions(1);
|
||||
config.options_mut().execution.coalesce_batches = false;
|
||||
config.options_mut().optimizer.repartition_file_scans = false;
|
||||
config
|
||||
} else {
|
||||
config
|
||||
};
|
||||
let custom_two_byte_record_delimiter = context
|
||||
.input
|
||||
.request
|
||||
@@ -835,7 +810,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_scan_range_keeps_single_partition_decode() {
|
||||
async fn parquet_scan_range_keeps_file_repartitioning() {
|
||||
let mut context = test_context();
|
||||
let request = &mut Arc::make_mut(&mut context.input).request;
|
||||
request.scan_range = Some(ScanRange {
|
||||
@@ -851,9 +826,7 @@ mod tests {
|
||||
.await
|
||||
.expect("Parquet ScanRange session should be created");
|
||||
|
||||
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
|
||||
assert_eq!(session.inner().config().batch_size(), 1);
|
||||
assert_eq!(session.inner().config().target_partitions(), 1);
|
||||
assert!(session.inner().config().options().optimizer.repartition_file_scans);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1033,39 +1006,6 @@ mod tests {
|
||||
assert_eq!(session.inner().config().target_partitions(), SessionConfig::new().target_partitions());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoder_batch_budgets_keep_parquet_downstream_execution_single_row() {
|
||||
let factory = SessionCtxFactory::new(true);
|
||||
for (budget, expected) in [(64 * 1024 * 1024, 256), (8 * 1024 * 1024, 32), (1024, 1)] {
|
||||
let session = factory
|
||||
.create_session_ctx_inner(&test_context(), None, JsonSource::default(), None, None, budget)
|
||||
.await
|
||||
.expect("CSV session");
|
||||
assert_eq!(session.inner().config().batch_size(), expected);
|
||||
}
|
||||
for parquet in [false, true] {
|
||||
let mut context = test_context();
|
||||
let input = Arc::make_mut(&mut context.input);
|
||||
input.request.input_serialization.csv = None;
|
||||
if parquet {
|
||||
input.request.input_serialization.parquet = Some(ParquetInput::default());
|
||||
} else {
|
||||
input.request.input_serialization.json = Some(JSONInput {
|
||||
type_: Some(JSONType::from_static(JSONType::LINES)),
|
||||
});
|
||||
}
|
||||
let session = factory.create_session_ctx(&context).await.expect("non-CSV session");
|
||||
assert_eq!(
|
||||
session.inner().config().batch_size(),
|
||||
if parquet { 1 } else { SessionConfig::new().batch_size() }
|
||||
);
|
||||
if parquet {
|
||||
assert!(!session.inner().config().options().execution.coalesce_batches);
|
||||
assert_eq!(session.inner().config().target_partitions(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_factory_applies_memory_limit() {
|
||||
let factory = SessionCtxFactory::new(true);
|
||||
|
||||
@@ -45,7 +45,6 @@ hotpath.workspace = true
|
||||
rustfs-s3select-api = { workspace = true }
|
||||
async-trait.workspace = true
|
||||
bytes.workspace = true
|
||||
csv-core.workspace = true
|
||||
datafusion = { workspace = true, default-features = false, features = ["parquet", "recursive_protection", "sql"] }
|
||||
derive_builder = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
@@ -57,8 +56,7 @@ tokio = { workspace = true, features = ["fs", "rt-multi-thread", "sync", "time"]
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
flate2.workspace = true
|
||||
rustfs-test-utils = { workspace = true }
|
||||
rustfs-test-utils = { workspace = true, features = ["put-object-commit-barrier"] }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -32,16 +32,6 @@
|
||||
|
||||
For comprehensive documentation, examples, and usage guides, please visit the main [RustFS repository](https://github.com/rustfs/rustfs).
|
||||
|
||||
## Resource limits
|
||||
|
||||
JSON LINES schema inference reads the first 1,000 nonblank records, preserving Arrow's type inference and source field order. It buffers at most 4 MiB of decompressed input. Reaching that byte limit cuts the sample at the last complete record, exactly as a shorter object would; it is an error only when no complete record fits. CSV inference reads only the first non-comment record (the header when enabled), with the same 4 MiB prefix limit. Quoting, escaping, comments, header modes and record delimiters still apply.
|
||||
|
||||
CSV schemas have at most 1,024 columns. JSON LINES schemas have at most 1,024 distinct fields across all nested objects in the inference sample. SQL expressions have at most 256 KiB and 1,024 projection expressions; wildcard expansion must also fit 1,024 output columns. Inputs exceeding these limits now return explicit errors, including inputs older versions attempted to process without these bounds. Parquet schema inference is unchanged apart from reading string and binary columns as view types; Parquet decoding and downstream execution use one-row batches on a single partition, with batch coalescing disabled to bound dictionary expansion during casts. JSON DOCUMENT input handling is unchanged.
|
||||
|
||||
CSV decoding uses at most 256 rows per batch, reduced further for smaller configured query memory limits. At 1,024 columns, Arrow's two-offset-per-cell reservation is at most 4 MiB on 64-bit systems and at most one sixteenth of the configured query budget (with a minimum one-row batch). Schema prefix storage and temporary inference work are reserved against the existing query memory pool. The configured memory limit can reject a sample before the 4 MiB hard prefix limit.
|
||||
|
||||
SQL parsing, schema inference for CSV and JSON LINES, and SQL planning run on blocking workers after query admission. Queued and running workers retain that admission until they finish, even if the HTTP request is canceled or its query deadline expires. Request cancellation stops waiting immediately; it cannot forcibly interrupt synchronous third-party code or admit replacement CPU work before that code exits.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the Apache License 2.0 - see the [LICENSE](../../LICENSE) file for details.
|
||||
|
||||
@@ -17,9 +17,8 @@ use datafusion::arrow::{
|
||||
error::ArrowError,
|
||||
json::reader::{ValueIter, infer_json_schema_from_iterator},
|
||||
};
|
||||
use rustfs_s3select_api::query::session::MAX_SELECT_COLUMNS;
|
||||
use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
|
||||
use std::{cell::Cell, collections::HashMap, fmt, rc::Rc, sync::Arc};
|
||||
use std::{collections::HashMap, fmt, sync::Arc};
|
||||
|
||||
/// Infers a JSON LINES schema with Arrow's types and the source's first-seen
|
||||
/// key order.
|
||||
@@ -28,9 +27,10 @@ use std::{cell::Cell, collections::HashMap, fmt, rc::Rc, sync::Arc};
|
||||
/// process-wide `serde_json/preserve_order` feature is enabled, so `SELECT *`
|
||||
/// would otherwise reorder fields and CSV output columns.
|
||||
pub(super) fn infer_schema(bytes: &[u8], max_records: usize) -> Result<Schema, ArrowError> {
|
||||
let schema = infer_json_schema_from_iterator(ValueIter::new(bytes, Some(max_records)))?;
|
||||
let mut order = KeyOrder::default();
|
||||
// Same records as `ValueIter`: one value per line, blank lines skipped.
|
||||
// Check width before Arrow merges records; retain its type inference.
|
||||
// Arrow has already rejected invalid UTF-8 and JSON within this prefix.
|
||||
let records = bytes
|
||||
.split(|byte| *byte == b'\n')
|
||||
.filter_map(|line| std::str::from_utf8(line).ok())
|
||||
@@ -43,7 +43,6 @@ pub(super) fn infer_schema(bytes: &[u8], max_records: usize) -> Result<Schema, A
|
||||
.deserialize(&mut deserializer)
|
||||
.map_err(|error| ArrowError::JsonError(format!("Not valid JSON: {error}")))?;
|
||||
}
|
||||
let schema = infer_json_schema_from_iterator(ValueIter::new(bytes, Some(max_records)))?;
|
||||
Ok(Schema::new_with_metadata(order.reorder(schema.fields()), schema.metadata().clone()))
|
||||
}
|
||||
|
||||
@@ -53,25 +52,16 @@ pub(super) fn infer_schema(bytes: &[u8], max_records: usize) -> Result<Schema, A
|
||||
struct KeyOrder {
|
||||
positions: HashMap<String, usize>,
|
||||
children: Vec<KeyOrder>,
|
||||
field_count: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl KeyOrder {
|
||||
fn child<E: de::Error>(&mut self, key: String) -> Result<&mut KeyOrder, E> {
|
||||
fn child(&mut self, key: String) -> &mut KeyOrder {
|
||||
let next = self.children.len();
|
||||
let position = *self.positions.entry(key).or_insert(next);
|
||||
if position == next {
|
||||
let count = self.field_count.get();
|
||||
if count >= MAX_SELECT_COLUMNS {
|
||||
return Err(E::custom("S3 Select JSON schema exceeds 1024 fields"));
|
||||
}
|
||||
self.field_count.set(count + 1);
|
||||
self.children.push(KeyOrder {
|
||||
field_count: Rc::clone(&self.field_count),
|
||||
..Default::default()
|
||||
});
|
||||
self.children.push(KeyOrder::default());
|
||||
}
|
||||
Ok(&mut self.children[position])
|
||||
&mut self.children[position]
|
||||
}
|
||||
|
||||
fn reorder(&self, fields: &Fields) -> Fields {
|
||||
@@ -117,7 +107,7 @@ impl<'de> Visitor<'de> for &mut KeyOrder {
|
||||
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<(), A::Error> {
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
map.next_value_seed(self.child::<A::Error>(key)?)?;
|
||||
map.next_value_seed(self.child(key))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -187,22 +177,6 @@ mod tests {
|
||||
assert_eq!(names(schema.fields()), ["b", "a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_json_schema_counts_nested_fields_across_the_sample() {
|
||||
let rows = (0..1023)
|
||||
.map(|index| format!("{{\"outer\":{{\"f{index}\":1}}}}\n"))
|
||||
.collect::<String>();
|
||||
let schema = infer_schema(rows.as_bytes(), 2000).expect("1024 total fields");
|
||||
assert_eq!(schema.fields().len(), 1);
|
||||
let overflow = format!("{rows}{{\"outer\":{{\"extra\":1}}}}\n");
|
||||
assert!(
|
||||
infer_schema(overflow.as_bytes(), 2000)
|
||||
.expect_err("nested width limit")
|
||||
.to_string()
|
||||
.contains("1024 fields")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_arrow_errors_for_invalid_records() {
|
||||
assert!(infer_schema(b"{\"a\":1}\n[1]\n", 1000).is_err());
|
||||
|
||||
@@ -31,7 +31,7 @@ use datafusion::{
|
||||
file_format::{DEFAULT_SCHEMA_INFER_MAX_RECORD, csv::CsvFormat, json::JsonFormat},
|
||||
listing::{ListingOptions, ListingTable, ListingTableConfig, ListingTableUrl},
|
||||
},
|
||||
error::{DataFusionError, Result as DFResult},
|
||||
error::Result as DFResult,
|
||||
execution::{RecordBatchStream, SendableRecordBatchStream},
|
||||
object_store::ObjectStoreExt as _,
|
||||
sql::sqlparser::parser::ParserError,
|
||||
@@ -65,13 +65,11 @@ use crate::{
|
||||
dispatcher::{json_document_table::JsonDocumentTable, json_lines_schema, parquet_table::ParquetSelectTable},
|
||||
execution::factory::QueryExecutionFactoryRef,
|
||||
instance::{DEFAULT_MAX_CONCURRENT_QUERIES, DEFAULT_QUERY_TIMEOUT_SECS},
|
||||
metadata::{MetadataProvider, TableHandleProviderRef, base_table::BaseTableProvider},
|
||||
metadata::{ContextProviderExtension, MetadataProvider, TableHandleProviderRef, base_table::BaseTableProvider},
|
||||
sql::logical::planner::DefaultLogicalPlanner,
|
||||
sql::planner::{prepare_s3_select_statement, validate_expanded_projection_width},
|
||||
sql::planner::prepare_s3_select_statement,
|
||||
};
|
||||
|
||||
use super::schema_inference::{PrefixBoundary, infer_csv_schema, read_schema_prefix};
|
||||
|
||||
static IGNORE: LazyLock<FileHeaderInfo> = LazyLock::new(|| FileHeaderInfo::from_static(FileHeaderInfo::IGNORE));
|
||||
static NONE: LazyLock<FileHeaderInfo> = LazyLock::new(|| FileHeaderInfo::from_static(FileHeaderInfo::NONE));
|
||||
static USE: LazyLock<FileHeaderInfo> = LazyLock::new(|| FileHeaderInfo::from_static(FileHeaderInfo::USE));
|
||||
@@ -168,27 +166,20 @@ impl QueryDispatcher for SimpleQueryDispatcher {
|
||||
let stmt = match query_state_machine.prepared_statement() {
|
||||
Some(statement) => statement.clone(),
|
||||
None => {
|
||||
let (statement, source) = self
|
||||
.prepare_query_statement_in_worker(query.content(), &query_tracker)
|
||||
.await?;
|
||||
let (statement, source) = self.prepare_query_statement(query.content())?;
|
||||
if source_path_requires_expansion(source.path()) {
|
||||
return Err(SelectError::DataSourcePathUnsupported.into());
|
||||
}
|
||||
statement
|
||||
}
|
||||
};
|
||||
let scheme_provider = self.build_scheme_provider(session, &query_tracker).await?;
|
||||
let machine = Arc::clone(&query_state_machine);
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
run_planning_worker(&query_tracker, "SQL plan", move || {
|
||||
validate_expanded_projection_width(&stmt, scheme_provider.source_column_count())?;
|
||||
let planner = DefaultLogicalPlanner::new(&scheme_provider);
|
||||
machine.begin_analyze();
|
||||
let plan = runtime.block_on(planner.prepared_statement_to_plan(stmt, &machine.session))?;
|
||||
machine.end_analyze();
|
||||
Ok(plan)
|
||||
})
|
||||
.await
|
||||
let scheme_provider = self.build_scheme_provider(session).await?;
|
||||
let logical_planner = DefaultLogicalPlanner::new(&scheme_provider);
|
||||
|
||||
let logical_plan = self
|
||||
.statement_to_logical_plan(stmt, &logical_planner, Arc::clone(&query_state_machine))
|
||||
.await?;
|
||||
Ok(logical_plan)
|
||||
})
|
||||
.await?;
|
||||
if !is_json_document_input(&self.input) {
|
||||
@@ -279,10 +270,7 @@ impl SimpleQueryDispatcher {
|
||||
// Keep parser and analyzer errors in the planning phase. Successful
|
||||
// preparation is cached here because the source path configures the
|
||||
// object store before schema inference starts.
|
||||
let (prepared_statement, source) = match self
|
||||
.run_with_query_deadline(&query_tracker, self.prepare_query_statement_in_worker(query.content(), &query_tracker))
|
||||
.await
|
||||
{
|
||||
let (prepared_statement, source) = match self.prepare_query_statement(query.content()) {
|
||||
Ok((statement, source)) => (Some(statement), source),
|
||||
Err(_) => (None, JsonSource::default()),
|
||||
};
|
||||
@@ -310,20 +298,6 @@ impl SimpleQueryDispatcher {
|
||||
Ok(Arc::new(state_machine))
|
||||
}
|
||||
|
||||
async fn prepare_query_statement_in_worker(
|
||||
&self,
|
||||
sql: &str,
|
||||
tracker: &QueryExecutionTracker,
|
||||
) -> QueryResult<(ExtStatement, JsonSource)> {
|
||||
// Reject before cloning the request into an owned blocking job.
|
||||
if sql.len() > crate::sql::parser::MAX_SQL_EXPRESSION_BYTES {
|
||||
return Err(ParserError::ParserError("S3 Select expression exceeds 256 KiB".to_owned()).into());
|
||||
}
|
||||
let dispatcher = self.clone();
|
||||
let sql = sql.to_owned();
|
||||
run_planning_worker(tracker, "SQL parse", move || dispatcher.prepare_query_statement(&sql)).await
|
||||
}
|
||||
|
||||
fn prepare_query_statement(&self, sql: &str) -> QueryResult<(ExtStatement, JsonSource)> {
|
||||
let mut statements = self.parser.parse(sql)?;
|
||||
if statements.len() > 1 {
|
||||
@@ -409,6 +383,22 @@ impl SimpleQueryDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
async fn statement_to_logical_plan<S: ContextProviderExtension + Send + Sync>(
|
||||
&self,
|
||||
stmt: ExtStatement,
|
||||
logical_planner: &DefaultLogicalPlanner<'_, S>,
|
||||
query_state_machine: Arc<QueryStateMachine>,
|
||||
) -> QueryResult<Plan> {
|
||||
// begin analyze
|
||||
query_state_machine.begin_analyze();
|
||||
let logical_plan = logical_planner
|
||||
.prepared_statement_to_plan(stmt, &query_state_machine.session)
|
||||
.await?;
|
||||
query_state_machine.end_analyze();
|
||||
|
||||
Ok(logical_plan)
|
||||
}
|
||||
|
||||
async fn start_logical_plan(&self, logical_plan: Plan, query_state_machine: Arc<QueryStateMachine>) -> QueryResult<Output> {
|
||||
let execution = self
|
||||
.query_execution_factory
|
||||
@@ -418,11 +408,7 @@ impl SimpleQueryDispatcher {
|
||||
execution.start().await
|
||||
}
|
||||
|
||||
async fn build_scheme_provider(
|
||||
&self,
|
||||
session: &SessionCtx,
|
||||
tracker: &QueryExecutionTracker,
|
||||
) -> QueryResult<MetadataProvider> {
|
||||
async fn build_scheme_provider(&self, session: &SessionCtx) -> QueryResult<MetadataProvider> {
|
||||
if self.input.request.input_serialization.parquet.is_some() {
|
||||
let provider = ParquetSelectTable::try_new(session.inner(), self.input.as_ref()).await?;
|
||||
let current_session_table_provider = self.build_table_handle_provider()?;
|
||||
@@ -445,7 +431,6 @@ impl SimpleQueryDispatcher {
|
||||
|
||||
let path = format!("s3://{}/{}", self.input.bucket, self.input.key);
|
||||
let table_path = ListingTableUrl::parse(path)?;
|
||||
let mut csv_format = None;
|
||||
let (listing_options, need_rename_volume_name, need_ignore_volume_name) =
|
||||
if let Some(csv) = self.input.request.input_serialization.csv.as_ref() {
|
||||
let mut need_rename_volume_name = false;
|
||||
@@ -508,7 +493,6 @@ impl SimpleQueryDispatcher {
|
||||
.with_comment(None)
|
||||
.with_newlines_in_values(true);
|
||||
}
|
||||
csv_format = Some(CsvFormat::default().with_options(file_format.options().clone()));
|
||||
(
|
||||
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
|
||||
need_rename_volume_name,
|
||||
@@ -525,39 +509,11 @@ impl SimpleQueryDispatcher {
|
||||
return Err(SelectError::InvalidDataSource.into());
|
||||
};
|
||||
|
||||
let store = session.inner().runtime_env().object_store(table_path.object_store())?;
|
||||
let boundary = csv_format
|
||||
.as_ref()
|
||||
.map(PrefixBoundary::csv)
|
||||
.unwrap_or_else(PrefixBoundary::json_lines);
|
||||
let prefix = read_schema_prefix(
|
||||
store
|
||||
.get(table_path.prefix())
|
||||
.await
|
||||
.map_err(DataFusionError::from)?
|
||||
.into_stream(),
|
||||
&session.inner().runtime_env().memory_pool,
|
||||
boundary,
|
||||
)
|
||||
.await?;
|
||||
// Account for decoded values, temporary field maps and Arrow schema
|
||||
// construction as well as the retained byte prefix.
|
||||
prefix
|
||||
.reservation
|
||||
.try_resize(prefix.bytes.capacity() + prefix.bytes.len().saturating_mul(16))?;
|
||||
let resolve_schema = run_planning_worker(tracker, "schema inference", move || {
|
||||
let schema = if let Some(format) = csv_format {
|
||||
infer_csv_schema(&prefix.bytes, &format)?
|
||||
} else {
|
||||
Arc::new(
|
||||
json_lines_schema::infer_schema(&prefix.bytes, DEFAULT_SCHEMA_INFER_MAX_RECORD)
|
||||
.map_err(DataFusionError::from)?,
|
||||
)
|
||||
};
|
||||
drop(prefix);
|
||||
Ok(schema)
|
||||
})
|
||||
.await?;
|
||||
let resolve_schema = if self.input.request.input_serialization.json.is_some() {
|
||||
infer_json_lines_schema(session, &table_path).await?
|
||||
} else {
|
||||
listing_options.infer_schema(session.inner(), &table_path).await?
|
||||
};
|
||||
let config = if need_rename_volume_name {
|
||||
let mut new_fields = Vec::new();
|
||||
for (i, field) in resolve_schema.fields().iter().enumerate() {
|
||||
@@ -598,48 +554,12 @@ impl SimpleQueryDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone)]
|
||||
struct PlanningWorkerProbe {
|
||||
phase: &'static str,
|
||||
entered: Arc<tokio::sync::Notify>,
|
||||
release: Arc<Mutex<std::sync::mpsc::Receiver<()>>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
static PLANNING_WORKER_PROBE: PlanningWorkerProbe;
|
||||
}
|
||||
|
||||
/// Admission is acquired before queueing and is owned by the worker, not its
|
||||
/// JoinHandle. Request cancellation cannot admit replacement CPU work while a
|
||||
/// detached parser, schema inference or planner is still queued/running.
|
||||
async fn run_planning_worker<T: Send + 'static>(
|
||||
tracker: &QueryExecutionTracker,
|
||||
phase: &'static str,
|
||||
work: impl FnOnce() -> QueryResult<T> + Send + 'static,
|
||||
) -> QueryResult<T> {
|
||||
let guard = tracker.query_guard().ok_or(QueryError::Cancel)?;
|
||||
#[cfg(test)]
|
||||
let probe = PLANNING_WORKER_PROBE
|
||||
.try_with(|probe| probe.clone())
|
||||
.ok()
|
||||
.filter(|probe| probe.phase == phase);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _guard = guard;
|
||||
#[cfg(test)]
|
||||
if let Some(probe) = probe {
|
||||
probe.entered.notify_one();
|
||||
probe
|
||||
.release
|
||||
.lock()
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("release planning worker");
|
||||
}
|
||||
work()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| DataFusionError::Execution(format!("S3 Select {phase} worker failed: {error}")))?
|
||||
/// Reads the object like DataFusion's `JsonFormat::infer_schema` does, but
|
||||
/// keeps the source key order (see `json_lines_schema::infer_schema`).
|
||||
async fn infer_json_lines_schema(session: &SessionCtx, table_path: &ListingTableUrl) -> DFResult<SchemaRef> {
|
||||
let store = session.inner().runtime_env().object_store(table_path.object_store())?;
|
||||
let bytes = store.get(table_path.prefix()).await?.bytes().await?;
|
||||
Ok(Arc::new(json_lines_schema::infer_schema(&bytes, DEFAULT_SCHEMA_INFER_MAX_RECORD)?))
|
||||
}
|
||||
|
||||
fn is_json_document_input(input: &SelectObjectContentInput) -> bool {
|
||||
@@ -974,7 +894,7 @@ mod tests {
|
||||
},
|
||||
},
|
||||
};
|
||||
use rustfs_test_utils::TestECStoreEnv;
|
||||
use rustfs_test_utils::{PutObjectCommitBarrier, TestECStoreEnv};
|
||||
use s3s::dto::{
|
||||
CSVInput, CSVOutput, ExpressionType, FileHeaderInfo, InputSerialization, JSONInput, JSONOutput, JSONType,
|
||||
OutputSerialization, ParquetInput, SelectObjectContentInput, SelectObjectContentRequest,
|
||||
@@ -1248,24 +1168,6 @@ mod tests {
|
||||
.expect("production query dispatcher should build")
|
||||
}
|
||||
|
||||
fn snapshot_marker_values(batch: &datafusion::arrow::record_batch::RecordBatch) -> Vec<String> {
|
||||
use datafusion::arrow::array::cast::AsArray;
|
||||
let column = batch.column(0);
|
||||
match column.data_type() {
|
||||
DataType::Utf8 => column
|
||||
.as_string::<i32>()
|
||||
.iter()
|
||||
.map(|value| value.expect("marker").to_owned())
|
||||
.collect(),
|
||||
DataType::Utf8View => column
|
||||
.as_string_view()
|
||||
.iter()
|
||||
.map(|value| value.expect("marker").to_owned())
|
||||
.collect(),
|
||||
other => panic!("unexpected marker type {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_utf8_output(output: Output) -> Vec<String> {
|
||||
let Output::StreamData(stream) = output else {
|
||||
panic!("snapshot query should return rows");
|
||||
@@ -1275,7 +1177,16 @@ mod tests {
|
||||
.await
|
||||
.expect("collect snapshot query output")
|
||||
.iter()
|
||||
.flat_map(snapshot_marker_values)
|
||||
.flat_map(|batch| {
|
||||
batch
|
||||
.column(0)
|
||||
.as_any()
|
||||
.downcast_ref::<StringArray>()
|
||||
.expect("snapshot marker column should be Utf8")
|
||||
.iter()
|
||||
.map(|value| value.expect("snapshot marker should not be null").to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1308,24 +1219,32 @@ mod tests {
|
||||
.expect("infer old-generation schema")
|
||||
.expect("SELECT should produce a logical plan");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), env.put_object_bytes(&input.bucket, &input.key, new_generation))
|
||||
.await
|
||||
.expect("overwrite must finish while the planned Query still owns its snapshot");
|
||||
let commit_barrier = PutObjectCommitBarrier::before_namespace(&input.bucket, &input.key);
|
||||
let writer_env = env;
|
||||
let writer_bucket = input.bucket.clone();
|
||||
let writer_object = input.key.clone();
|
||||
let writer = tokio::spawn(async move {
|
||||
writer_env
|
||||
.put_object_bytes(&writer_bucket, &writer_object, new_generation)
|
||||
.await;
|
||||
});
|
||||
commit_barrier.wait_until_paused().await;
|
||||
commit_barrier.release_and_wait_until_namespace_pending().await;
|
||||
assert!(!writer.is_finished(), "overwrite must wait for the SelectObjectContent snapshot");
|
||||
|
||||
let output = dispatcher
|
||||
.execute_logical_plan(logical_plan, query_state_machine)
|
||||
.await
|
||||
.expect("scan old-generation rows");
|
||||
let values = collect_utf8_output(output).await;
|
||||
assert_eq!(values, expected_old_markers);
|
||||
assert!(!writer.is_finished(), "overwrite must remain blocked while Query owns the snapshot");
|
||||
|
||||
// A reader already pinned during planning may finish with old bytes.
|
||||
// Any subsequent range must reject the replacement generation.
|
||||
match dispatcher.execute_logical_plan(logical_plan, query_state_machine).await {
|
||||
Ok(Output::StreamData(stream)) => match stream.try_collect::<Vec<_>>().await {
|
||||
Ok(batches) => {
|
||||
let values: Vec<String> = batches.iter().flat_map(snapshot_marker_values).collect();
|
||||
assert_eq!(values, expected_old_markers);
|
||||
}
|
||||
Err(error) => assert!(error.to_string().contains("object read semantics changed"), "{error}"),
|
||||
},
|
||||
Err(error) => assert!(error.to_string().contains("object read semantics changed"), "{error}"),
|
||||
Ok(_) => panic!("snapshot query must return rows or a consistency error"),
|
||||
}
|
||||
drop(query);
|
||||
tokio::time::timeout(Duration::from_secs(5), writer)
|
||||
.await
|
||||
.expect("overwrite should finish after snapshot release")
|
||||
.expect("overwrite task should join");
|
||||
}
|
||||
|
||||
fn json_snapshot_input() -> Arc<SelectObjectContentInput> {
|
||||
@@ -1468,42 +1387,6 @@ mod tests {
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn production_parquet_dictionary_select_keeps_views_and_single_row_filter_casts() {
|
||||
use datafusion::arrow::array::cast::AsArray;
|
||||
let mut input = parquet_snapshot_input();
|
||||
let request = Arc::make_mut(&mut input);
|
||||
request.bucket = "select-dictionary-budget".to_owned();
|
||||
request.request.expression = "SELECT CAST(v AS VARCHAR) FROM S3Object WHERE length(v) > 0 LIMIT 2".to_owned();
|
||||
let env = snapshot_test_env().await;
|
||||
env.make_bucket(&input.bucket, false).await;
|
||||
let bytes = crate::dispatcher::parquet_table::dictionary_parquet_for_test(1024 * 1024, 8192);
|
||||
assert!(bytes.len() < 2 * 1024 * 1024);
|
||||
env.put_object_bytes(&input.bucket, &input.key, bytes).await;
|
||||
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
|
||||
let query = Query::new_with_snapshot(
|
||||
QueryContext {
|
||||
input: Arc::clone(&input),
|
||||
},
|
||||
input.request.expression.clone(),
|
||||
snapshot,
|
||||
);
|
||||
let dispatcher = production_dispatcher(input);
|
||||
let output = dispatcher.execute_query(&query).await.expect("production dictionary SELECT");
|
||||
let mut stream = output.into_record_batch_stream().expect("record stream");
|
||||
for _ in 0..2 {
|
||||
let batch = stream.try_next().await.expect("budgeted decode").expect("row");
|
||||
assert_eq!(batch.num_rows(), 1);
|
||||
assert_eq!(batch.column(0).data_type(), &DataType::Utf8View);
|
||||
assert_eq!(batch.column(0).as_string_view().value(0).len(), 1024 * 1024);
|
||||
assert!(
|
||||
batch.get_array_memory_size() < 2 * 1024 * 1024,
|
||||
"dictionary references must not materialize 8 GiB"
|
||||
);
|
||||
}
|
||||
assert!(stream.try_next().await.expect("EOF").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn json_schema_inference_and_scan_use_one_snapshot_generation() {
|
||||
run_snapshot_generation_race(
|
||||
@@ -1527,7 +1410,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn planner_failure_does_not_block_overwrite_while_query_is_alive() {
|
||||
async fn planner_failure_drops_snapshot_and_unblocks_overwrite() {
|
||||
let mut input = json_snapshot_input();
|
||||
let input_mut = Arc::make_mut(&mut input);
|
||||
input_mut.bucket = "s3select-planner-failure-snapshot".to_string();
|
||||
@@ -1551,19 +1434,34 @@ mod tests {
|
||||
.await
|
||||
.expect("build production snapshot session");
|
||||
|
||||
let commit_barrier = PutObjectCommitBarrier::before_namespace(&input.bucket, &input.key);
|
||||
let writer_env = env;
|
||||
let writer_bucket = input.bucket.clone();
|
||||
let writer_object = input.key.clone();
|
||||
let writer = tokio::spawn(async move {
|
||||
writer_env
|
||||
.put_object_bytes(&writer_bucket, &writer_object, b"{\"new_schema_poison\":1629}\n".to_vec())
|
||||
.await;
|
||||
});
|
||||
commit_barrier.wait_until_paused().await;
|
||||
commit_barrier.release_and_wait_until_namespace_pending().await;
|
||||
assert!(!writer.is_finished(), "overwrite must wait for the planner's snapshot");
|
||||
|
||||
let Err(error) = dispatcher.build_logical_plan(Arc::clone(&query_state_machine)).await else {
|
||||
panic!("missing schema binding must fail planning");
|
||||
};
|
||||
assert!(error.to_string().contains("missing_binding"));
|
||||
assert!(
|
||||
!writer.is_finished(),
|
||||
"planner failure must not release snapshots still owned by Query/qsm"
|
||||
);
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
env.put_object_bytes(&input.bucket, &input.key, b"{\"new_schema_poison\":1629}\n".to_vec()),
|
||||
)
|
||||
.await
|
||||
.expect("overwrite must finish even while failed planner and Query retain their snapshots");
|
||||
drop(query_state_machine);
|
||||
drop(query);
|
||||
tokio::time::timeout(Duration::from_secs(5), writer)
|
||||
.await
|
||||
.expect("overwrite should finish after failed-plan snapshot release")
|
||||
.expect("overwrite task should join");
|
||||
}
|
||||
|
||||
fn test_query_tracker(
|
||||
@@ -1843,350 +1741,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn bounded_inference_machine(
|
||||
input: SelectObjectContentInput,
|
||||
data: Vec<u8>,
|
||||
) -> (Arc<SimpleQueryDispatcher>, Arc<QueryStateMachine>) {
|
||||
let input = Arc::new(input);
|
||||
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
|
||||
let dispatcher = test_dispatcher_for_input(
|
||||
Arc::clone(&input),
|
||||
Arc::new(Semaphore::new(1)),
|
||||
Duration::from_secs(30),
|
||||
Arc::new(SqlQueryExecutionFactory::new(optimizer, Arc::new(LocalScheduler {}))),
|
||||
);
|
||||
let query = Query::new(
|
||||
QueryContext {
|
||||
input: Arc::clone(&input),
|
||||
},
|
||||
input.request.expression.clone(),
|
||||
);
|
||||
let machine = dispatcher.build_query_state_machine(query).await.expect("build query");
|
||||
let url = ObjectStoreUrl::parse(format!("s3://{}", input.bucket)).expect("store URL");
|
||||
let store = machine.session.inner().runtime_env().object_store(&url).expect("store");
|
||||
store
|
||||
.put(&Path::from(input.key.as_str()), data.into())
|
||||
.await
|
||||
.expect("put object");
|
||||
(dispatcher, machine)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_schema_inference_rejects_wide_csv_and_json_before_planning() {
|
||||
for json in [false, true] {
|
||||
let mut input = test_input();
|
||||
input.request.expression = "SELECT * FROM S3Object".to_owned();
|
||||
let data = if json {
|
||||
input.request.input_serialization.csv = None;
|
||||
input.request.input_serialization.json = Some(JSONInput {
|
||||
type_: Some(JSONType::from_static(JSONType::LINES)),
|
||||
});
|
||||
format!("{{{}}}\n", (0..1025).map(|index| format!("\"c{index}\":1")).collect::<Vec<_>>().join(","))
|
||||
} else {
|
||||
input.request.input_serialization.csv.as_mut().expect("CSV").file_header_info =
|
||||
Some(FileHeaderInfo::from_static(FileHeaderInfo::USE));
|
||||
format!("{}\n", (0..1025).map(|index| format!("c{index}")).collect::<Vec<_>>().join(","))
|
||||
};
|
||||
let (dispatcher, machine) = bounded_inference_machine(input, data.into_bytes()).await;
|
||||
let error = dispatcher
|
||||
.build_logical_plan(machine)
|
||||
.await
|
||||
.err()
|
||||
.expect("wide schema rejected");
|
||||
assert!(error.to_string().contains("1024"), "{error}");
|
||||
assert_eq!(dispatcher.query_admission.available_permits(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_json_lines_prefix_accepts_complete_sample_before_byte_limit() {
|
||||
for rows in [1, 1000, 600] {
|
||||
let mut input = test_input();
|
||||
input.request.expression = "SELECT * FROM S3Object LIMIT 1".to_owned();
|
||||
input.request.input_serialization.csv = None;
|
||||
input.request.input_serialization.json = Some(JSONInput {
|
||||
type_: Some(JSONType::from_static(JSONType::LINES)),
|
||||
});
|
||||
let input = Arc::new(input);
|
||||
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
|
||||
let dispatcher = SimpleQueryDispatcherBuilder::default()
|
||||
.with_input(Arc::clone(&input))
|
||||
.with_default_table_provider(Arc::new(BaseTableProvider::default()))
|
||||
.with_session_factory(Arc::new(SessionCtxFactory::new(true)))
|
||||
.with_parser(Arc::new(DefaultParser::default()))
|
||||
.with_query_execution_factory(Arc::new(SqlQueryExecutionFactory::new(optimizer, Arc::new(LocalScheduler {}))))
|
||||
.with_func_manager(Arc::new(SimpleFunctionMetadataManager::default()))
|
||||
.with_memory_limit_bytes(128 * 1024 * 1024)
|
||||
.with_query_admission(Arc::new(Semaphore::new(1)))
|
||||
.with_query_timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("supported 128 MiB query budget");
|
||||
let query = Query::new(
|
||||
QueryContext {
|
||||
input: Arc::clone(&input),
|
||||
},
|
||||
input.request.expression.clone(),
|
||||
);
|
||||
let machine = dispatcher.build_query_state_machine(query).await.expect("query machine");
|
||||
let url = ObjectStoreUrl::parse(format!("s3://{}", input.bucket)).expect("store URL");
|
||||
let store = machine.session.inner().runtime_env().object_store(&url).expect("store");
|
||||
let payload = "x".repeat(if rows == 1000 { 32 } else { 8192 });
|
||||
let data = format!("{{\"payload\":\"{payload}\"}}\n").repeat(rows);
|
||||
store
|
||||
.put(&Path::from(input.key.as_str()), data.into_bytes().into())
|
||||
.await
|
||||
.expect("write valid JSON Lines object");
|
||||
let plan = dispatcher
|
||||
.build_logical_plan(Arc::clone(&machine))
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{rows} valid records must plan from a complete bounded sample: {error}"))
|
||||
.expect("SELECT plan");
|
||||
let output = dispatcher.execute_logical_plan(plan, machine).await.expect("execute query");
|
||||
let mut stream = output.into_record_batch_stream().expect("record batches");
|
||||
let mut returned = 0;
|
||||
while let Some(batch) = stream.next().await {
|
||||
let batch = batch.expect("decode supported records");
|
||||
returned += batch.num_rows();
|
||||
}
|
||||
assert_eq!(returned, 1, "LIMIT 1 yields one supported row for {rows} input records");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_json_lines_prefix_rejects_overflow_without_truncating_records() {
|
||||
let mut input = test_input();
|
||||
input.request.expression = "SELECT * FROM S3Object".to_owned();
|
||||
input.request.input_serialization.csv = None;
|
||||
input.request.input_serialization.json = Some(JSONInput {
|
||||
type_: Some(JSONType::from_static(JSONType::LINES)),
|
||||
});
|
||||
// No complete record fits the inference prefix. Do not manufacture a
|
||||
// partial record for the JSON parser at the byte limit.
|
||||
let data = format!("{{\"payload\":\"{}\"}}\n", "x".repeat(4 * 1024 * 1024));
|
||||
let (dispatcher, machine) = bounded_inference_machine(input, data.into_bytes()).await;
|
||||
let error = dispatcher.build_logical_plan(machine).await.err().expect("prefix limit");
|
||||
assert!(error.to_string().contains("4 MiB prefix"), "{error}");
|
||||
assert_eq!(dispatcher.query_admission.available_permits(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_csv_decoder_executes_maximum_width_in_small_batches() {
|
||||
let mut input = test_input();
|
||||
input.request.expression = "SELECT * FROM S3Object".to_owned();
|
||||
input.request.input_serialization.csv.as_mut().expect("CSV").file_header_info =
|
||||
Some(FileHeaderInfo::from_static(FileHeaderInfo::USE));
|
||||
let header = (0..1024).map(|index| format!("c{index}")).collect::<Vec<_>>().join(",");
|
||||
let row = format!("{}\n", vec!["value"; 1024].join(","));
|
||||
let data = format!("{header}\n{}", row.repeat(600));
|
||||
let (dispatcher, machine) = bounded_inference_machine(input, data.into_bytes()).await;
|
||||
assert_eq!(machine.session.inner().config().batch_size(), 256);
|
||||
let plan = dispatcher
|
||||
.build_logical_plan(Arc::clone(&machine))
|
||||
.await
|
||||
.expect("plan")
|
||||
.expect("SELECT plan");
|
||||
let output = dispatcher.execute_logical_plan(plan, machine).await.expect("execute");
|
||||
let mut stream = output.into_record_batch_stream().expect("batches");
|
||||
let mut rows = 0;
|
||||
while let Some(batch) = stream.next().await {
|
||||
let batch = batch.expect("decode bounded CSV");
|
||||
assert_eq!(batch.num_columns(), 1024);
|
||||
assert!(batch.num_rows() <= 256);
|
||||
assert_eq!(array_value_to_string(batch.column(1023), 0).expect("value"), "value");
|
||||
rows += batch.num_rows();
|
||||
}
|
||||
assert_eq!(rows, 600);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn bounded_json_lines_gzip_inference_does_not_read_the_object_tail() {
|
||||
use std::io::{Read, Write};
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
for _ in 0..500_000 {
|
||||
encoder.write_all(b"{\"z\":1,\"a\":\"value\"}\n").expect("gzip input");
|
||||
}
|
||||
let mut compressed = encoder.finish().expect("gzip");
|
||||
// The original whole-object .bytes() path must encounter this invalid
|
||||
// trailer; prefix inference and LIMIT 1 must not reach it.
|
||||
let trailer = compressed.len() - 8;
|
||||
compressed[trailer] ^= 1;
|
||||
assert!(
|
||||
flate2::read::GzDecoder::new(compressed.as_slice())
|
||||
.read_to_end(&mut Vec::new())
|
||||
.is_err()
|
||||
);
|
||||
let env = snapshot_test_env().await;
|
||||
let mut input = (*json_snapshot_input()).clone();
|
||||
input.bucket = "bounded-lines-gzip".to_owned();
|
||||
input.key = "records.gz".to_owned();
|
||||
input.request.expression = "SELECT * FROM S3Object LIMIT 1".to_owned();
|
||||
input.request.input_serialization.compression_type =
|
||||
Some(s3s::dto::CompressionType::from_static(s3s::dto::CompressionType::GZIP));
|
||||
env.make_bucket(&input.bucket, false).await;
|
||||
env.put_object_bytes(&input.bucket, &input.key, compressed).await;
|
||||
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
|
||||
let input = Arc::new(input);
|
||||
let dispatcher = production_dispatcher(Arc::clone(&input));
|
||||
let query = Query::new_with_snapshot(
|
||||
QueryContext {
|
||||
input: Arc::clone(&input),
|
||||
},
|
||||
input.request.expression.clone(),
|
||||
snapshot,
|
||||
);
|
||||
let machine = dispatcher.build_query_state_machine(query).await.expect("machine");
|
||||
let plan = dispatcher
|
||||
.build_logical_plan(Arc::clone(&machine))
|
||||
.await
|
||||
.expect("bounded inference succeeds")
|
||||
.expect("plan");
|
||||
let output = dispatcher.execute_logical_plan(plan, machine).await.expect("execute");
|
||||
let batches: Vec<_> = output
|
||||
.into_record_batch_stream()
|
||||
.expect("stream")
|
||||
.try_collect()
|
||||
.await
|
||||
.expect("one row");
|
||||
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
|
||||
assert_eq!(field_names(batches[0].schema().fields()), ["z", "a"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn blocking_select_planning_keeps_runtime_responsive_and_admission_after_cancel() {
|
||||
for phase in ["SQL parse", "schema inference", "SQL plan"] {
|
||||
let (release, receiver) = std::sync::mpsc::channel();
|
||||
let probe = super::PlanningWorkerProbe {
|
||||
phase,
|
||||
entered: Arc::new(tokio::sync::Notify::new()),
|
||||
release: Arc::new(parking_lot::Mutex::new(receiver)),
|
||||
};
|
||||
let entered = Arc::clone(&probe.entered);
|
||||
let mut input = test_input();
|
||||
input.request.expression = "SELECT * FROM S3Object".to_owned();
|
||||
let (dispatcher, machine) = bounded_inference_machine(input, b"name\nvalue\n".to_vec()).await;
|
||||
let admission = Arc::clone(&dispatcher.query_admission);
|
||||
let task = if phase == "SQL parse" {
|
||||
let query = machine.query.clone();
|
||||
machine
|
||||
.query_tracker()
|
||||
.expect("tracker")
|
||||
.finish(&dispatcher.query_execution_owner);
|
||||
tokio::spawn(super::PLANNING_WORKER_PROBE.scope(probe, async move {
|
||||
dispatcher.build_query_state_machine(query).await?;
|
||||
Ok::<_, QueryError>(())
|
||||
}))
|
||||
} else {
|
||||
tokio::spawn(super::PLANNING_WORKER_PROBE.scope(probe, async move {
|
||||
dispatcher.build_logical_plan(machine).await?;
|
||||
Ok::<_, QueryError>(())
|
||||
}))
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(2), entered.notified())
|
||||
.await
|
||||
.expect("worker entered without blocking Tokio");
|
||||
// The single-thread executor stays responsive while the worker is held.
|
||||
tokio::time::timeout(Duration::from_secs(1), tokio::task::yield_now())
|
||||
.await
|
||||
.expect("runtime heartbeat");
|
||||
assert_eq!(admission.available_permits(), 0, "{phase}");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("canceled request").is_cancelled());
|
||||
assert_eq!(
|
||||
admission.available_permits(),
|
||||
0,
|
||||
"cancellation must retain the {phase} worker's admission"
|
||||
);
|
||||
release.send(()).expect("release worker");
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while admission.available_permits() != 1 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("worker completion releases admission");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_select_parser_retains_admission_while_queued_after_cancel() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.max_blocking_threads(1)
|
||||
.build()
|
||||
.expect("runtime");
|
||||
runtime.block_on(async {
|
||||
let (release, receiver) = std::sync::mpsc::channel();
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let signal = Arc::clone(&entered);
|
||||
let blocker = tokio::task::spawn_blocking(move || {
|
||||
signal.notify_one();
|
||||
receiver
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("release occupied worker");
|
||||
});
|
||||
entered.notified().await;
|
||||
let admission = Arc::new(Semaphore::new(1));
|
||||
let (dispatcher, input) = test_dispatcher(Arc::clone(&admission), Duration::from_secs(30));
|
||||
let query = Query::new(QueryContext { input }, "SELECT * FROM S3Object".to_owned());
|
||||
let request = tokio::spawn(async move { dispatcher.build_query_state_machine(query).await.map(|_| ()) });
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while admission.available_permits() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("admission before worker queue");
|
||||
request.abort();
|
||||
assert!(request.await.expect_err("canceled request").is_cancelled());
|
||||
assert_eq!(admission.available_permits(), 0, "queued worker must retain admission");
|
||||
release.send(()).expect("unblock worker pool");
|
||||
blocker.await.expect("blocking slot released");
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while admission.available_permits() != 1 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("queued parser finished");
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_select_projection_checks_wildcard_expansion_before_planner() {
|
||||
let mut input = test_input();
|
||||
input.request.expression = "SELECT *, * FROM S3Object".to_owned();
|
||||
input.request.input_serialization.csv.as_mut().expect("CSV").file_header_info =
|
||||
Some(FileHeaderInfo::from_static(FileHeaderInfo::USE));
|
||||
let data = format!("{}\n", (0..600).map(|index| format!("c{index}")).collect::<Vec<_>>().join(","));
|
||||
let (dispatcher, machine) = bounded_inference_machine(input, data.into_bytes()).await;
|
||||
let error = dispatcher
|
||||
.build_logical_plan(machine)
|
||||
.await
|
||||
.err()
|
||||
.expect("expanded projection rejected");
|
||||
assert!(error.to_string().contains("expanded projection exceeds 1024"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_select_projection_is_rejected_before_object_schema_access() {
|
||||
let (dispatcher, input) = test_dispatcher(Arc::new(Semaphore::new(1)), Duration::from_secs(30));
|
||||
let sql = format!(
|
||||
"SELECT {} FROM S3Object",
|
||||
(0..30_000).map(|index| format!("{index}")).collect::<Vec<_>>().join(",")
|
||||
);
|
||||
let machine = dispatcher
|
||||
.build_query_state_machine(Query::new(QueryContext { input }, sql))
|
||||
.await
|
||||
.expect("deferred parser error");
|
||||
// No object is present: a storage lookup would produce the wrong error.
|
||||
let error = dispatcher
|
||||
.build_logical_plan(machine)
|
||||
.await
|
||||
.err()
|
||||
.expect("wide projection rejected");
|
||||
assert!(error.to_string().contains("1024 columns"), "{error}");
|
||||
}
|
||||
|
||||
async fn json_lines_plan_schema(data: &'static [u8], sql: &str) -> SchemaRef {
|
||||
let mut input = test_input();
|
||||
input.key = "records.json".to_owned();
|
||||
|
||||
@@ -17,4 +17,3 @@ mod json_document_table;
|
||||
mod json_lines_schema;
|
||||
pub mod manager;
|
||||
mod parquet_table;
|
||||
mod schema_inference;
|
||||
|
||||
@@ -17,20 +17,16 @@ use std::{fmt, ops::Range, sync::Arc};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use datafusion::{
|
||||
arrow::{
|
||||
array::{Array, cast::AsArray, downcast_dictionary_array},
|
||||
datatypes::{DataType, FieldRef, Schema, SchemaRef},
|
||||
record_batch::RecordBatch,
|
||||
},
|
||||
arrow::datatypes::SchemaRef,
|
||||
catalog::Session,
|
||||
common::{DataFusionError, Result as DFResult},
|
||||
common::Result as DFResult,
|
||||
datasource::{
|
||||
TableProvider,
|
||||
listing::{ListingTableUrl, PartitionedFile},
|
||||
physical_plan::{FileScanConfigBuilder, ParquetSource, parquet::ParquetAccessPlan},
|
||||
source::DataSourceExec,
|
||||
},
|
||||
execution::{memory_pool::MemoryLimit, object_store::ObjectStoreUrl},
|
||||
execution::object_store::ObjectStoreUrl,
|
||||
logical_expr::{Expr, TableProviderFilterPushDown, TableType},
|
||||
object_store::{Error as ObjectStoreError, ObjectStore, ObjectStoreExt, path::Path},
|
||||
parquet::{
|
||||
@@ -38,13 +34,9 @@ use datafusion::{
|
||||
errors::{ParquetError, Result as ParquetResult},
|
||||
file::metadata::{ParquetMetaData, ParquetMetaDataReader, RowGroupMetaData},
|
||||
},
|
||||
physical_plan::{
|
||||
ExecutionPlan, SendableRecordBatchStream,
|
||||
stream::RecordBatchStreamAdapter,
|
||||
streaming::{PartitionStream, StreamingTableExec},
|
||||
},
|
||||
physical_plan::ExecutionPlan,
|
||||
};
|
||||
use futures::{FutureExt, TryFutureExt, TryStreamExt, future::BoxFuture, stream};
|
||||
use futures::{FutureExt, TryFutureExt, future::BoxFuture};
|
||||
use rustfs_s3select_api::{
|
||||
QueryResult,
|
||||
object_store::{SelectScanRange, scan_range_from_bounds},
|
||||
@@ -58,7 +50,6 @@ pub struct ParquetSelectTable {
|
||||
object_path: String,
|
||||
object_size: u64,
|
||||
access_plan: Option<Arc<ParquetAccessPlan>>,
|
||||
expanded_row_budget: usize,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ParquetSelectTable {
|
||||
@@ -76,24 +67,14 @@ struct ObjectStoreParquetReader {
|
||||
store: Arc<dyn ObjectStore>,
|
||||
path: Path,
|
||||
file_size: u64,
|
||||
metadata_read_limit: usize,
|
||||
}
|
||||
|
||||
impl AsyncFileReader for ObjectStoreParquetReader {
|
||||
fn get_bytes(&mut self, range: Range<u64>) -> BoxFuture<'_, ParquetResult<Bytes>> {
|
||||
if range.end.saturating_sub(range.start) > self.metadata_read_limit as u64 {
|
||||
return async { Err(ParquetError::External(Box::new(parquet_budget_error()))) }.boxed();
|
||||
}
|
||||
self.store.get_range(&self.path, range).map_err(parquet_store_error).boxed()
|
||||
}
|
||||
|
||||
fn get_byte_ranges(&mut self, ranges: Vec<Range<u64>>) -> BoxFuture<'_, ParquetResult<Vec<Bytes>>> {
|
||||
let total = ranges
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, range| sum.checked_add(range.end.saturating_sub(range.start)));
|
||||
if total.is_none_or(|total| total > self.metadata_read_limit as u64) {
|
||||
return async { Err(ParquetError::External(Box::new(parquet_budget_error()))) }.boxed();
|
||||
}
|
||||
async move { self.store.get_ranges(&self.path, &ranges).await.map_err(parquet_store_error) }.boxed()
|
||||
}
|
||||
|
||||
@@ -130,34 +111,17 @@ impl ParquetSelectTable {
|
||||
.await
|
||||
.map_err(datafusion::common::DataFusionError::from)?;
|
||||
|
||||
let memory_budget = match state.runtime_env().memory_pool.memory_limit() {
|
||||
MemoryLimit::Finite(bytes) => bytes,
|
||||
_ => rustfs_s3select_api::query::session::DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES,
|
||||
};
|
||||
let reader = ObjectStoreParquetReader {
|
||||
store: Arc::clone(&store),
|
||||
path: object_location,
|
||||
file_size: object_meta.size,
|
||||
metadata_read_limit: (memory_budget / 16).max(1),
|
||||
};
|
||||
let builder = ParquetRecordBatchStreamBuilder::new(reader)
|
||||
.await
|
||||
.map_err(datafusion::common::DataFusionError::from)?;
|
||||
let schema = Arc::new(Schema::new_with_metadata(
|
||||
builder
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| parquet_view_field(field, 0))
|
||||
.collect::<DFResult<Vec<_>>>()?,
|
||||
builder.schema().metadata().clone(),
|
||||
));
|
||||
let schema = Arc::clone(builder.schema());
|
||||
let metadata = Arc::clone(builder.metadata());
|
||||
// This is admission for valid Parquet declarations, not a replacement
|
||||
// for limits inside the footer parser or native compression codecs.
|
||||
let expanded_row_budget = (memory_budget / 16).max(1);
|
||||
let access_plan = parquet_access_plan(input, object_meta.size, metadata.as_ref())?;
|
||||
admit_parquet_decode(metadata.as_ref(), access_plan.as_deref(), memory_budget / 4)?;
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
schema,
|
||||
@@ -165,12 +129,11 @@ impl ParquetSelectTable {
|
||||
object_path: input.key.clone(),
|
||||
object_size: object_meta.size,
|
||||
access_plan,
|
||||
expanded_row_budget,
|
||||
}))
|
||||
}
|
||||
|
||||
fn partitioned_file(&self) -> PartitionedFile {
|
||||
let file = PartitionedFile::new(self.object_path.clone(), self.object_size).with_arrow_schema(Arc::clone(&self.schema));
|
||||
let file = PartitionedFile::new(self.object_path.clone(), self.object_size);
|
||||
if let Some(access_plan) = self.access_plan.as_ref() {
|
||||
file.with_extension(access_plan.as_ref().clone())
|
||||
} else {
|
||||
@@ -191,41 +154,20 @@ impl TableProvider for ParquetSelectTable {
|
||||
|
||||
async fn scan(
|
||||
&self,
|
||||
state: &dyn Session,
|
||||
_state: &dyn Session,
|
||||
projection: Option<&Vec<usize>>,
|
||||
filters: &[Expr],
|
||||
limit: Option<usize>,
|
||||
) -> DFResult<Arc<dyn ExecutionPlan>> {
|
||||
if state.config_options().execution.batch_size.get() != 1 || state.config_options().execution.coalesce_batches {
|
||||
return Err(DataFusionError::Plan(
|
||||
"S3 Select Parquet requires single-row execution without batch coalescing".into(),
|
||||
));
|
||||
}
|
||||
let scan_limit = if filters.is_empty() { limit } else { None };
|
||||
let file_source = Arc::new(ParquetSource::new(Arc::clone(&self.schema)));
|
||||
let config = FileScanConfigBuilder::new(self.object_store_url.clone(), file_source)
|
||||
.with_file(self.partitioned_file())
|
||||
.with_projection_indices(projection.cloned())?
|
||||
.with_limit(scan_limit)
|
||||
.with_batch_size(Some(1))
|
||||
.build();
|
||||
let input: Arc<dyn ExecutionPlan> = DataSourceExec::from_data_source(config);
|
||||
let schema = input.schema();
|
||||
let partition: Arc<dyn PartitionStream> = Arc::new(ParquetBudgetPartition {
|
||||
input,
|
||||
schema: Arc::clone(&schema),
|
||||
expanded_row_budget: self.expanded_row_budget,
|
||||
});
|
||||
// An opaque source boundary keeps expression/cast pushdown outside the
|
||||
// expansion check. One file group is read serially, without repartitioning.
|
||||
Ok(Arc::new(StreamingTableExec::try_new(
|
||||
schema,
|
||||
vec![partition],
|
||||
None,
|
||||
[],
|
||||
false,
|
||||
scan_limit,
|
||||
)?))
|
||||
let plan: Arc<dyn ExecutionPlan> = DataSourceExec::from_data_source(config);
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn supports_filters_pushdown(&self, filters: &[&Expr]) -> DFResult<Vec<TableProviderFilterPushDown>> {
|
||||
@@ -233,161 +175,6 @@ impl TableProvider for ParquetSelectTable {
|
||||
}
|
||||
}
|
||||
|
||||
const PARQUET_MAX_TYPE_DEPTH: usize = 64;
|
||||
|
||||
fn parquet_budget_error() -> DataFusionError {
|
||||
DataFusionError::ResourcesExhausted("S3 Select Parquet decoded data exceeds the query memory budget".into())
|
||||
}
|
||||
|
||||
fn parquet_view_field(field: &FieldRef, depth: usize) -> DFResult<FieldRef> {
|
||||
if depth >= PARQUET_MAX_TYPE_DEPTH {
|
||||
return Err(parquet_budget_error());
|
||||
}
|
||||
let recurse = |child: &FieldRef| parquet_view_field(child, depth + 1);
|
||||
let data_type = match field.data_type() {
|
||||
DataType::Utf8 | DataType::LargeUtf8 => DataType::Utf8View,
|
||||
DataType::Binary | DataType::LargeBinary => DataType::BinaryView,
|
||||
DataType::List(child) => DataType::List(recurse(child)?),
|
||||
DataType::LargeList(child) => DataType::LargeList(recurse(child)?),
|
||||
DataType::ListView(child) => DataType::ListView(recurse(child)?),
|
||||
DataType::LargeListView(child) => DataType::LargeListView(recurse(child)?),
|
||||
DataType::FixedSizeList(child, size) => DataType::FixedSizeList(recurse(child)?, *size),
|
||||
DataType::Map(child, sorted) => DataType::Map(recurse(child)?, *sorted),
|
||||
DataType::Struct(fields) => DataType::Struct(fields.iter().map(recurse).collect::<DFResult<Vec<_>>>()?.into()),
|
||||
// Preserve the dictionary: its indices already share the value buffer.
|
||||
// A later cast is still checked by the actual per-row expansion budget.
|
||||
other => other.clone(),
|
||||
};
|
||||
Ok(Arc::new(field.as_ref().clone().with_data_type(data_type)))
|
||||
}
|
||||
|
||||
fn admit_parquet_decode(metadata: &ParquetMetaData, access_plan: Option<&ParquetAccessPlan>, limit: usize) -> DFResult<()> {
|
||||
// The pinned DataFusion reader drains one active row group before fetching
|
||||
// the next. Parquet's DataRequest hands its ranges to that group and clears
|
||||
// them from PushBuffers. Do not reject a large file of many small groups.
|
||||
for (index, group) in metadata.row_groups().iter().enumerate() {
|
||||
if access_plan.is_some_and(|plan| !plan.should_scan(index)) {
|
||||
continue;
|
||||
}
|
||||
let mut resident = 0usize;
|
||||
for column in group.columns() {
|
||||
let decoded = usize::try_from(column.uncompressed_size()).map_err(|_| parquet_budget_error())?;
|
||||
let compressed = usize::try_from(column.compressed_size()).map_err(|_| parquet_budget_error())?;
|
||||
let values = usize::try_from(column.num_values()).map_err(|_| parquet_budget_error())?;
|
||||
// Include a conservative view/offset/validity allowance, including
|
||||
// repeated nested values that can all belong to a single row.
|
||||
resident = resident
|
||||
.checked_add(compressed)
|
||||
.and_then(|sum| sum.checked_add(decoded))
|
||||
.and_then(|sum| values.checked_mul(32).and_then(|views| sum.checked_add(views)))
|
||||
.ok_or_else(parquet_budget_error)?;
|
||||
if resident > limit {
|
||||
return Err(parquet_budget_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParquetBudgetPartition {
|
||||
input: Arc<dyn ExecutionPlan>,
|
||||
schema: SchemaRef,
|
||||
expanded_row_budget: usize,
|
||||
}
|
||||
|
||||
impl PartitionStream for ParquetBudgetPartition {
|
||||
fn schema(&self) -> &SchemaRef {
|
||||
&self.schema
|
||||
}
|
||||
|
||||
fn execute(&self, context: Arc<datafusion::execution::TaskContext>) -> SendableRecordBatchStream {
|
||||
let schema = Arc::clone(&self.schema);
|
||||
let input = match self.input.execute(0, context) {
|
||||
Ok(input) => input,
|
||||
Err(error) => return Box::pin(RecordBatchStreamAdapter::new(schema, stream::once(async move { Err(error) }))),
|
||||
};
|
||||
let budget = self.expanded_row_budget;
|
||||
let rows = stream::try_unfold((input, None::<RecordBatch>, 0usize), move |(mut input, mut batch, mut row)| async move {
|
||||
loop {
|
||||
if let Some(current) = batch.as_ref()
|
||||
&& row < current.num_rows()
|
||||
{
|
||||
let mut remaining = budget;
|
||||
for column in current.columns() {
|
||||
charge_parquet_value(column.as_ref(), row, 0, &mut remaining)?;
|
||||
}
|
||||
let output = current.slice(row, 1);
|
||||
row += 1;
|
||||
return Ok(Some((output, (input, batch, row))));
|
||||
}
|
||||
batch = input.try_next().await?;
|
||||
row = 0;
|
||||
if batch.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
});
|
||||
Box::pin(RecordBatchStreamAdapter::new(schema, rows))
|
||||
}
|
||||
}
|
||||
|
||||
fn charge_parquet_value(array: &dyn Array, row: usize, depth: usize, remaining: &mut usize) -> DFResult<()> {
|
||||
if depth >= PARQUET_MAX_TYPE_DEPTH {
|
||||
return Err(parquet_budget_error());
|
||||
}
|
||||
// Offset, validity and view bookkeeping still costs memory for empty cells.
|
||||
*remaining = remaining.checked_sub(32).ok_or_else(parquet_budget_error)?;
|
||||
if array.is_null(row) {
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = match array.data_type() {
|
||||
DataType::Utf8View => array.as_string_view().value(row).len(),
|
||||
DataType::BinaryView => array.as_binary_view().value(row).len(),
|
||||
DataType::Utf8 => array.as_string::<i32>().value(row).len(),
|
||||
DataType::LargeUtf8 => array.as_string::<i64>().value(row).len(),
|
||||
DataType::Binary => array.as_binary::<i32>().value(row).len(),
|
||||
DataType::LargeBinary => array.as_binary::<i64>().value(row).len(),
|
||||
DataType::FixedSizeBinary(size) => usize::try_from(*size).map_err(|_| parquet_budget_error())?,
|
||||
DataType::Struct(_) => {
|
||||
for child in array.as_struct().columns() {
|
||||
charge_parquet_value(child.as_ref(), row, depth + 1, remaining)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
DataType::Dictionary(_, _) => {
|
||||
return downcast_dictionary_array!(array => {
|
||||
let key = array.key(row).ok_or_else(parquet_budget_error)?;
|
||||
charge_parquet_value(array.values().as_ref(), key, depth + 1, remaining)
|
||||
}, _ => Err(parquet_budget_error()));
|
||||
}
|
||||
DataType::List(_)
|
||||
| DataType::LargeList(_)
|
||||
| DataType::ListView(_)
|
||||
| DataType::LargeListView(_)
|
||||
| DataType::FixedSizeList(_, _)
|
||||
| DataType::Map(_, _) => {
|
||||
let values: Arc<dyn Array> = match array.data_type() {
|
||||
DataType::List(_) => array.as_list::<i32>().value(row),
|
||||
DataType::LargeList(_) => array.as_list::<i64>().value(row),
|
||||
DataType::ListView(_) => array.as_list_view::<i32>().value(row),
|
||||
DataType::LargeListView(_) => array.as_list_view::<i64>().value(row),
|
||||
DataType::FixedSizeList(_, _) => array.as_fixed_size_list().value(row),
|
||||
DataType::Map(_, _) => Arc::new(array.as_map().value(row)),
|
||||
_ => return Err(parquet_budget_error()),
|
||||
};
|
||||
for child_row in 0..values.len() {
|
||||
charge_parquet_value(values.as_ref(), child_row, depth + 1, remaining)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
DataType::Boolean | DataType::Null => 1,
|
||||
other => other.primitive_width().ok_or_else(parquet_budget_error)?,
|
||||
};
|
||||
*remaining = remaining.checked_sub(bytes).ok_or_else(parquet_budget_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parquet_access_plan(
|
||||
input: &SelectObjectContentInput,
|
||||
object_size: u64,
|
||||
@@ -431,11 +218,6 @@ fn parquet_store_error(err: ObjectStoreError) -> ParquetError {
|
||||
ParquetError::External(Box::new(err))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn dictionary_parquet_for_test(value_bytes: usize, rows: usize) -> Vec<u8> {
|
||||
tests::dictionary_file(value_bytes, rows, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -546,347 +328,8 @@ mod tests {
|
||||
assert_eq!(error.select_error(), SelectError::ParquetParsingError);
|
||||
}
|
||||
|
||||
pub(super) fn dictionary_file(value_bytes: usize, rows: usize, compressed: bool) -> Vec<u8> {
|
||||
use datafusion::arrow::array::{DictionaryArray, StringArray};
|
||||
use datafusion::arrow::datatypes::Int32Type;
|
||||
use datafusion::parquet::{
|
||||
arrow::arrow_writer::ArrowWriterOptions, basic::Compression, file::properties::WriterProperties,
|
||||
};
|
||||
let values = Arc::new(StringArray::from(vec!["x".repeat(value_bytes)]));
|
||||
let keys = Int32Array::from(vec![0; rows]);
|
||||
let column = DictionaryArray::<Int32Type>::try_new(keys, values).expect("dictionary");
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("v", column.data_type().clone(), true)]));
|
||||
let batch = RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(column)]).expect("dictionary batch");
|
||||
let properties = WriterProperties::builder()
|
||||
.set_dictionary_enabled(true)
|
||||
.set_dictionary_page_size_limit(value_bytes * 2)
|
||||
.set_compression(if compressed {
|
||||
Compression::SNAPPY
|
||||
} else {
|
||||
Compression::UNCOMPRESSED
|
||||
})
|
||||
.build();
|
||||
let options = ArrowWriterOptions::new()
|
||||
.with_properties(properties)
|
||||
.with_skip_arrow_metadata(true);
|
||||
let mut bytes = Vec::new();
|
||||
let mut writer = ArrowWriter::try_new_with_options(&mut bytes, schema, options).expect("writer");
|
||||
writer.write(&batch).expect("dictionary data");
|
||||
writer.close().expect("footer");
|
||||
bytes
|
||||
}
|
||||
|
||||
async fn register_parquet_fixture(context: &SessionContext, store: &Arc<InMemory>, bytes: Vec<u8>) {
|
||||
store
|
||||
.put(&Path::from("dictionary.parquet"), Bytes::from(bytes).into())
|
||||
.await
|
||||
.expect("fixture");
|
||||
let table = ParquetSelectTable::try_new(&context.state(), &parquet_input("dictionary.parquet"))
|
||||
.await
|
||||
.expect("valid dictionary metadata");
|
||||
context.register_table("source", table).expect("table");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dictionary_byte_array_uses_shared_views_and_single_row_casts_after_filter() {
|
||||
let store = Arc::new(InMemory::new());
|
||||
let context = parquet_session(store.clone());
|
||||
let bytes = dictionary_file(1024 * 1024, 8192, false);
|
||||
assert!(bytes.len() < 2 * 1024 * 1024, "legal small dictionary file");
|
||||
register_parquet_fixture(&context, &store, bytes).await;
|
||||
let mut rows = context
|
||||
.sql("SELECT v FROM source LIMIT 2")
|
||||
.await
|
||||
.expect("plan")
|
||||
.execute_stream()
|
||||
.await
|
||||
.expect("stream");
|
||||
let first = rows.try_next().await.expect("decode").expect("first row");
|
||||
let second = rows.try_next().await.expect("decode").expect("second row");
|
||||
assert_eq!(first.num_rows(), 1);
|
||||
assert_eq!(second.num_rows(), 1);
|
||||
assert_eq!(first.column(0).data_type(), &DataType::Utf8View);
|
||||
assert!(first.get_array_memory_size() < 2 * 1024 * 1024, "no 8192-fold dictionary expansion");
|
||||
let first_views = first.column(0).as_string_view();
|
||||
let second_views = second.column(0).as_string_view();
|
||||
assert!(
|
||||
first_views.data_buffers()[0].ptr_eq(&second_views.data_buffers()[0]),
|
||||
"rows share the dictionary bytes"
|
||||
);
|
||||
drop(rows);
|
||||
|
||||
// BYTEA requests a materializing Binary cast. FilterExec must not
|
||||
// rebuild an 8192-row batch before evaluating that cast.
|
||||
let frame = context
|
||||
.sql("SELECT CAST(v AS BYTEA) FROM source WHERE length(v) > 1 LIMIT 2")
|
||||
.await
|
||||
.expect("cast plan");
|
||||
let physical = frame.clone().create_physical_plan().await.expect("physical plan");
|
||||
let display = datafusion::physical_plan::displayable(physical.as_ref())
|
||||
.indent(false)
|
||||
.to_string();
|
||||
assert!(display.contains("FilterExec"), "{display}");
|
||||
assert!(!display.contains("CoalesceBatchesExec"), "{display}");
|
||||
let mut rows = frame.execute_stream().await.expect("cast stream");
|
||||
for _ in 0..2 {
|
||||
let batch = rows.try_next().await.expect("bounded cast").expect("cast row");
|
||||
assert_eq!(batch.num_rows(), 1);
|
||||
assert_eq!(batch.column(0).data_type(), &DataType::Binary);
|
||||
assert_eq!(batch.column(0).as_binary::<i32>().value(0).len(), 1024 * 1024);
|
||||
assert!(batch.get_array_memory_size() < 2 * 1024 * 1024);
|
||||
}
|
||||
let frame = context
|
||||
.table("source")
|
||||
.await
|
||||
.expect("table")
|
||||
.filter(datafusion::prelude::col("v").is_not_null())
|
||||
.expect("filter")
|
||||
.select(vec![datafusion::logical_expr::cast(
|
||||
datafusion::prelude::col("v"),
|
||||
DataType::LargeUtf8,
|
||||
)])
|
||||
.expect("large string cast")
|
||||
.limit(0, Some(2))
|
||||
.expect("limit");
|
||||
let mut rows = frame.execute_stream().await.expect("LargeUtf8 stream");
|
||||
for _ in 0..2 {
|
||||
let batch = rows.try_next().await.expect("bounded large string cast").expect("row");
|
||||
assert_eq!(batch.num_rows(), 1);
|
||||
assert_eq!(batch.column(0).data_type(), &DataType::LargeUtf8);
|
||||
assert!(batch.get_array_memory_size() < 2 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_actual_expanded_single_row_is_rejected_before_cast() {
|
||||
let store = Arc::new(InMemory::new());
|
||||
let context = parquet_session(store.clone());
|
||||
// Compression keeps the stored input small. Valid footer declarations
|
||||
// fit the decode allowance, but this one expanded row exceeds 4 MiB.
|
||||
register_parquet_fixture(&context, &store, dictionary_file(5 * 1024 * 1024, 2, true)).await;
|
||||
let mut stream = context
|
||||
.sql("SELECT CAST(v AS BYTEA) FROM source")
|
||||
.await
|
||||
.expect("plan")
|
||||
.execute_stream()
|
||||
.await
|
||||
.expect("stream");
|
||||
let error = stream.try_next().await.expect_err("reject before materializing the cast");
|
||||
assert!(error.to_string().contains("query memory budget"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_many_small_row_groups_can_exceed_the_query_budget_in_total() {
|
||||
use datafusion::arrow::array::StringArray;
|
||||
use datafusion::parquet::{
|
||||
arrow::arrow_writer::ArrowWriterOptions, basic::Compression, file::properties::WriterProperties,
|
||||
};
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Utf8, false)]));
|
||||
let column = Arc::new(StringArray::from(vec!["x".repeat(64 * 1024)]));
|
||||
let batch = RecordBatch::try_new(Arc::clone(&schema), vec![column]).expect("small row group");
|
||||
let properties = WriterProperties::builder().set_compression(Compression::UNCOMPRESSED).build();
|
||||
let options = ArrowWriterOptions::new()
|
||||
.with_properties(properties)
|
||||
.with_skip_arrow_metadata(true);
|
||||
let mut bytes = Vec::new();
|
||||
let mut writer = ArrowWriter::try_new_with_options(&mut bytes, schema, options).expect("writer");
|
||||
for _ in 0..80 {
|
||||
writer.write(&batch).expect("row");
|
||||
writer.flush().expect("separate row group");
|
||||
}
|
||||
writer.close().expect("footer");
|
||||
assert!(bytes.len() > 4 * 1024 * 1024);
|
||||
let store = Arc::new(InMemory::new());
|
||||
let context = parquet_session_with_memory(store.clone(), 4 * 1024 * 1024);
|
||||
register_parquet_fixture(&context, &store, bytes).await;
|
||||
let mut rows = context
|
||||
.sql("SELECT length(v) FROM source")
|
||||
.await
|
||||
.expect("plan")
|
||||
.execute_stream()
|
||||
.await
|
||||
.expect("stream");
|
||||
let mut count = 0;
|
||||
while let Some(batch) = rows.try_next().await.expect("serial row group decode") {
|
||||
assert_eq!(batch.num_rows(), 1);
|
||||
count += batch.num_rows();
|
||||
}
|
||||
assert_eq!(count, 80);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_scan_range_admits_only_the_selected_row_groups() {
|
||||
use datafusion::arrow::array::StringArray;
|
||||
use datafusion::parquet::{
|
||||
arrow::arrow_writer::ArrowWriterOptions, basic::Compression, file::properties::WriterProperties,
|
||||
};
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Utf8, false)]));
|
||||
let properties = WriterProperties::builder().set_compression(Compression::SNAPPY).build();
|
||||
let options = ArrowWriterOptions::new()
|
||||
.with_properties(properties)
|
||||
.with_skip_arrow_metadata(true);
|
||||
let mut bytes = Vec::new();
|
||||
let mut writer = ArrowWriter::try_new_with_options(&mut bytes, Arc::clone(&schema), options).expect("writer");
|
||||
for value in ["selected".to_string(), "x".repeat(2 * 1024 * 1024)] {
|
||||
let column = Arc::new(StringArray::from(vec![value]));
|
||||
let batch = RecordBatch::try_new(Arc::clone(&schema), vec![column]).expect("row");
|
||||
writer.write(&batch).expect("write row group");
|
||||
writer.flush().expect("separate row groups");
|
||||
}
|
||||
writer.close().expect("footer");
|
||||
let bytes = Bytes::from(bytes);
|
||||
let builder = ParquetRecordBatchReaderBuilder::try_new(bytes.clone()).expect("metadata");
|
||||
let starts = builder
|
||||
.metadata()
|
||||
.row_groups()
|
||||
.iter()
|
||||
.map(|group| i64::try_from(row_group_start_offset(group).expect("row group start")).expect("offset"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(starts.len(), 2);
|
||||
assert_ne!(starts[0], starts[1]);
|
||||
let store = Arc::new(InMemory::new());
|
||||
store.put(&Path::from("range.parquet"), bytes.into()).await.expect("fixture");
|
||||
let context = parquet_session_with_memory(store, 4 * 1024 * 1024);
|
||||
let mut input = parquet_input("range.parquet");
|
||||
input.request.scan_range = Some(ScanRange {
|
||||
start: Some(starts[0]),
|
||||
end: Some(starts[0]),
|
||||
});
|
||||
let table = ParquetSelectTable::try_new(&context.state(), &input)
|
||||
.await
|
||||
.expect("an unselected large row group must not reject the small range");
|
||||
context.register_table("source", table).expect("table");
|
||||
let rows = context
|
||||
.sql("SELECT v FROM source")
|
||||
.await
|
||||
.expect("query")
|
||||
.collect()
|
||||
.await
|
||||
.expect("decode only the selected row group");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].num_rows(), 1);
|
||||
assert_eq!(rows[0].column(0).as_string_view().value(0), "selected");
|
||||
input.request.scan_range = Some(ScanRange {
|
||||
start: Some(starts[1]),
|
||||
end: Some(starts[1]),
|
||||
});
|
||||
let error = match ParquetSelectTable::try_new(&context.state(), &input).await {
|
||||
Ok(_) => panic!("the selected oversized row group must still be rejected before decoding"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.to_string().contains("query memory budget"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_actual_row_allowance_tracks_the_configured_query_budget() {
|
||||
let store = Arc::new(InMemory::new());
|
||||
let context = parquet_session_with_memory(store.clone(), 4 * 1024 * 1024);
|
||||
register_parquet_fixture(&context, &store, dictionary_file(512 * 1024, 2, true)).await;
|
||||
let mut rows = context
|
||||
.sql("SELECT v FROM source")
|
||||
.await
|
||||
.expect("plan")
|
||||
.execute_stream()
|
||||
.await
|
||||
.expect("stream");
|
||||
let error = rows
|
||||
.try_next()
|
||||
.await
|
||||
.expect_err("512 KiB row exceeds the configured 256 KiB allowance");
|
||||
assert!(error.to_string().contains("query memory budget"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_valid_compressed_dictionary_is_rejected_before_decoder_allocation() {
|
||||
let store = Arc::new(InMemory::new());
|
||||
let context = parquet_session(store.clone());
|
||||
let bytes = dictionary_file(20 * 1024 * 1024, 1, true);
|
||||
assert!(bytes.len() < 2 * 1024 * 1024);
|
||||
store
|
||||
.put(&Path::from("large.parquet"), Bytes::from(bytes).into())
|
||||
.await
|
||||
.expect("fixture");
|
||||
let error = match ParquetSelectTable::try_new(&context.state(), &parquet_input("large.parquet")).await {
|
||||
Ok(_) => panic!("valid declared decoded size must be admitted before page decode"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.to_string().contains("query memory budget"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_nested_views_decode_through_the_actual_file_schema() {
|
||||
use datafusion::arrow::array::{ListArray, StringArray};
|
||||
use datafusion::arrow::buffer::OffsetBuffer;
|
||||
use datafusion::parquet::arrow::arrow_writer::ArrowWriterOptions;
|
||||
let store = Arc::new(InMemory::new());
|
||||
let context = parquet_session(store.clone());
|
||||
let child = Arc::new(Field::new("item", DataType::Utf8, true));
|
||||
let values = Arc::new(StringArray::from(vec![Some("first value beyond inline size"), None, Some("last")]));
|
||||
let list = Arc::new(ListArray::new(child, OffsetBuffer::new(vec![0, 2, 3].into()), values, None));
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("nested", list.data_type().clone(), false)]));
|
||||
let batch = RecordBatch::try_new(Arc::clone(&schema), vec![list]).expect("nested rows");
|
||||
let mut bytes = Vec::new();
|
||||
let mut writer =
|
||||
ArrowWriter::try_new_with_options(&mut bytes, schema, ArrowWriterOptions::new().with_skip_arrow_metadata(true))
|
||||
.expect("nested writer");
|
||||
writer.write(&batch).expect("rows");
|
||||
writer.close().expect("footer");
|
||||
register_parquet_fixture(&context, &store, bytes).await;
|
||||
let mut rows = context
|
||||
.sql("SELECT nested FROM source")
|
||||
.await
|
||||
.expect("plan")
|
||||
.execute_stream()
|
||||
.await
|
||||
.expect("stream");
|
||||
let first = rows.try_next().await.expect("decode").expect("first row");
|
||||
let values = first.column(0).as_list::<i32>().value(0);
|
||||
assert_eq!(values.data_type(), &DataType::Utf8View);
|
||||
assert_eq!(values.as_string_view().value(0), "first value beyond inline size");
|
||||
assert!(values.is_null(1));
|
||||
let second = rows.try_next().await.expect("decode").expect("second row");
|
||||
assert_eq!(second.column(0).as_list::<i32>().value(0).as_string_view().value(0), "last");
|
||||
assert!(rows.try_next().await.expect("EOF").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parquet_nested_view_schema_and_expansion_follow_logical_references() {
|
||||
use datafusion::arrow::array::{ListArray, StringViewArray};
|
||||
use datafusion::arrow::buffer::OffsetBuffer;
|
||||
let child = Arc::new(
|
||||
Field::new("item", DataType::Utf8, true)
|
||||
.with_metadata(std::collections::HashMap::from([("custom".to_owned(), "kept".to_owned())])),
|
||||
);
|
||||
let field = Arc::new(Field::new("nested", DataType::List(child), false));
|
||||
let converted = parquet_view_field(&field, 0).expect("recursive views");
|
||||
let DataType::List(child) = converted.data_type() else { panic!("list preserved") };
|
||||
assert_eq!(child.data_type(), &DataType::Utf8View);
|
||||
assert_eq!(child.metadata().get("custom").map(String::as_str), Some("kept"));
|
||||
let values = Arc::new(StringViewArray::from(vec!["long repeated dictionary value"; 4]));
|
||||
let list = ListArray::new(Arc::clone(child), OffsetBuffer::new(vec![0, 4].into()), values, None);
|
||||
let mut budget = 64;
|
||||
assert!(
|
||||
charge_parquet_value(&list, 0, 0, &mut budget).is_err(),
|
||||
"nested references count separately"
|
||||
);
|
||||
let mut budget = 1024;
|
||||
charge_parquet_value(&list, 0, 0, &mut budget).expect("small nested row");
|
||||
}
|
||||
|
||||
fn parquet_session(store: Arc<dyn ObjectStore>) -> SessionContext {
|
||||
parquet_session_with_memory(store, 64 * 1024 * 1024)
|
||||
}
|
||||
|
||||
fn parquet_session_with_memory(store: Arc<dyn ObjectStore>, budget: usize) -> SessionContext {
|
||||
let mut config = datafusion::prelude::SessionConfig::new()
|
||||
.with_batch_size(1)
|
||||
.with_target_partitions(1);
|
||||
config.options_mut().execution.coalesce_batches = false;
|
||||
let runtime = datafusion::execution::runtime_env::RuntimeEnvBuilder::new()
|
||||
.with_memory_limit(budget, 1.0)
|
||||
.build()
|
||||
.expect("query memory pool");
|
||||
let context = SessionContext::new_with_config_rt(config, Arc::new(runtime));
|
||||
let context = SessionContext::new();
|
||||
let store_url = ObjectStoreUrl::parse("s3://test-bucket").expect("valid test object store URL");
|
||||
context.register_object_store(store_url.as_ref(), store);
|
||||
context
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use bytes::Bytes;
|
||||
use csv_core::{ReadRecordResult, Reader, ReaderBuilder, Terminator};
|
||||
use datafusion::{
|
||||
arrow::{
|
||||
csv::reader::Format,
|
||||
datatypes::{DataType, Schema, SchemaRef},
|
||||
},
|
||||
datasource::file_format::{DEFAULT_SCHEMA_INFER_MAX_RECORD, csv::CsvFormat},
|
||||
error::{DataFusionError, Result},
|
||||
execution::memory_pool::{MemoryConsumer, MemoryPool, MemoryReservation},
|
||||
object_store::Error as ObjectStoreError,
|
||||
};
|
||||
use futures::{StreamExt, stream::BoxStream};
|
||||
use rustfs_s3select_api::query::session::MAX_SELECT_COLUMNS;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) const MAX_SCHEMA_PREFIX_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
pub(super) struct SchemaPrefix {
|
||||
pub bytes: Vec<u8>,
|
||||
pub reservation: MemoryReservation,
|
||||
}
|
||||
|
||||
pub(super) enum PrefixBoundary {
|
||||
JsonLines { records: usize, line_start: usize },
|
||||
Csv { reader: Box<Reader>, fields: usize },
|
||||
}
|
||||
|
||||
impl PrefixBoundary {
|
||||
pub fn json_lines() -> Self {
|
||||
Self::JsonLines {
|
||||
records: 0,
|
||||
line_start: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn csv(format: &CsvFormat) -> Self {
|
||||
let options = format.options();
|
||||
let mut builder = ReaderBuilder::new();
|
||||
builder
|
||||
.delimiter(options.delimiter)
|
||||
.quote(options.quote)
|
||||
.escape(options.escape)
|
||||
.comment(options.comment);
|
||||
if let Some(terminator) = options.terminator {
|
||||
builder.terminator(Terminator::Any(terminator));
|
||||
}
|
||||
Self::Csv {
|
||||
reader: Box::new(builder.build()),
|
||||
fields: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn consume(&mut self, input: &[u8], prefix: &[u8]) -> Result<(usize, bool)> {
|
||||
match self {
|
||||
Self::JsonLines { records, line_start } => {
|
||||
for (index, byte) in input.iter().enumerate() {
|
||||
if *byte != b'\n' {
|
||||
continue;
|
||||
}
|
||||
let end = prefix.len() - input.len() + index + 1;
|
||||
// The complete line is available even when a UTF-8 space
|
||||
// straddles input chunks. Match Arrow's blank-line rule.
|
||||
let nonblank = !std::str::from_utf8(&prefix[*line_start..end])
|
||||
.unwrap_or("invalid UTF-8")
|
||||
.trim()
|
||||
.is_empty();
|
||||
*line_start = end;
|
||||
if nonblank {
|
||||
*records += 1;
|
||||
}
|
||||
if *records == DEFAULT_SCHEMA_INFER_MAX_RECORD {
|
||||
return Ok((index + 1, true));
|
||||
}
|
||||
}
|
||||
Ok((input.len(), false))
|
||||
}
|
||||
Self::Csv { reader, fields } => {
|
||||
let mut consumed = 0;
|
||||
let mut output = [0u8; 8192];
|
||||
let mut ends = [0usize; MAX_SELECT_COLUMNS + 1];
|
||||
loop {
|
||||
let (result, read, _, count) =
|
||||
reader.read_record(&input[consumed..], &mut output, &mut ends[..MAX_SELECT_COLUMNS + 1 - *fields]);
|
||||
consumed += read;
|
||||
*fields += count;
|
||||
if *fields > MAX_SELECT_COLUMNS {
|
||||
return Err(DataFusionError::ResourcesExhausted(
|
||||
"S3 Select CSV schema exceeds 1024 columns".to_owned(),
|
||||
));
|
||||
}
|
||||
match result {
|
||||
ReadRecordResult::Record | ReadRecordResult::End => return Ok((consumed, true)),
|
||||
ReadRecordResult::InputEmpty => return Ok((consumed, false)),
|
||||
ReadRecordResult::OutputFull if consumed == input.len() => return Ok((consumed, false)),
|
||||
ReadRecordResult::OutputFull => {}
|
||||
ReadRecordResult::OutputEndsFull => {
|
||||
return Err(DataFusionError::ResourcesExhausted(
|
||||
"S3 Select CSV schema exceeds 1024 columns".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads only complete inference records. Hitting the byte bound with no
|
||||
/// complete record is an error; with at least one complete record the sample
|
||||
/// is cut there, exactly as a short object or the record count would cut it.
|
||||
pub(super) async fn read_schema_prefix(
|
||||
mut stream: BoxStream<'static, std::result::Result<Bytes, ObjectStoreError>>,
|
||||
memory_pool: &Arc<dyn MemoryPool>,
|
||||
mut boundary: PrefixBoundary,
|
||||
) -> Result<SchemaPrefix> {
|
||||
let reservation = MemoryConsumer::new("S3 Select schema prefix").register(memory_pool);
|
||||
let mut bytes = Vec::new();
|
||||
let mut parsed = 0;
|
||||
while let Some(chunk) = stream.next().await.transpose()? {
|
||||
let mut offset = 0;
|
||||
while offset < chunk.len() {
|
||||
let remaining = MAX_SCHEMA_PREFIX_BYTES - bytes.len();
|
||||
if remaining == 0 {
|
||||
if let PrefixBoundary::JsonLines { records, line_start } = &boundary
|
||||
&& *records > 0
|
||||
{
|
||||
bytes.truncate(*line_start);
|
||||
return Ok(SchemaPrefix { bytes, reservation });
|
||||
}
|
||||
return Err(prefix_limit_error());
|
||||
}
|
||||
let end = chunk.len().min(offset + remaining);
|
||||
let required = bytes.len() + end - offset;
|
||||
if required > bytes.capacity() {
|
||||
let capacity = required.next_power_of_two();
|
||||
reservation.try_resize(capacity)?;
|
||||
bytes
|
||||
.try_reserve_exact(capacity - bytes.len())
|
||||
.map_err(|_| prefix_limit_error())?;
|
||||
reservation.try_resize(bytes.capacity())?;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk[offset..end]);
|
||||
// csv-core recognizes a BOM only when its three bytes arrive
|
||||
// together, but treats a BOM with no following bytes as EOF after
|
||||
// stripping it. Wait for data or the actual end of the stream.
|
||||
if parsed == 0 && (bytes.len() < 3 || bytes.as_slice() == b"\xef\xbb\xbf") {
|
||||
offset = end;
|
||||
continue;
|
||||
}
|
||||
let (consumed, done) = boundary.consume(&bytes[parsed..], &bytes)?;
|
||||
parsed += consumed;
|
||||
bytes.truncate(parsed);
|
||||
offset = end;
|
||||
if done {
|
||||
return Ok(SchemaPrefix { bytes, reservation });
|
||||
}
|
||||
}
|
||||
}
|
||||
if parsed < bytes.len() {
|
||||
boundary.consume(&bytes[parsed..], &bytes)?;
|
||||
}
|
||||
boundary.consume(&[], &bytes)?;
|
||||
Ok(SchemaPrefix { bytes, reservation })
|
||||
}
|
||||
|
||||
fn prefix_limit_error() -> DataFusionError {
|
||||
DataFusionError::ResourcesExhausted("S3 Select schema inference exceeds the 4 MiB prefix limit".to_owned())
|
||||
}
|
||||
|
||||
pub(super) fn infer_csv_schema(bytes: &[u8], format: &CsvFormat) -> Result<SchemaRef> {
|
||||
let options = format.options();
|
||||
let mut arrow = Format::default()
|
||||
.with_header(options.has_header.unwrap_or(false))
|
||||
.with_delimiter(options.delimiter)
|
||||
.with_quote(options.quote);
|
||||
if let Some(escape) = options.escape {
|
||||
arrow = arrow.with_escape(escape);
|
||||
}
|
||||
if let Some(comment) = options.comment {
|
||||
arrow = arrow.with_comment(comment);
|
||||
}
|
||||
if let Some(terminator) = options.terminator {
|
||||
arrow = arrow.with_terminator(terminator);
|
||||
}
|
||||
let (schema, _) = arrow.infer_schema(bytes, Some(0))?;
|
||||
// Preserve CsvFormat's zero-record Utf8 inference and duplicate-header
|
||||
// merge semantics. Width has already been checked before Arrow allocation.
|
||||
let fields = schema
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.as_ref().clone().with_data_type(DataType::Utf8))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Arc::new(Schema::try_merge([Schema::new(fields)])?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use datafusion::execution::memory_pool::GreedyMemoryPool;
|
||||
use futures::stream;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn pool() -> Arc<dyn MemoryPool> {
|
||||
Arc::new(GreedyMemoryPool::new(64 * 1024 * 1024))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_json_prefix_keeps_only_complete_records_at_byte_limit() {
|
||||
let first = b"{\"a\":1}\n";
|
||||
let mut input = first.to_vec();
|
||||
input.extend_from_slice(b"{\"a\":\"");
|
||||
input.extend(std::iter::repeat_n(b'x', MAX_SCHEMA_PREFIX_BYTES));
|
||||
input.extend_from_slice(b"\"}\n");
|
||||
for width in [8192, MAX_SCHEMA_PREFIX_BYTES + 128] {
|
||||
let chunks = input
|
||||
.chunks(width)
|
||||
.map(|chunk| Ok(Bytes::copy_from_slice(chunk)))
|
||||
.collect::<Vec<_>>();
|
||||
let prefix = read_schema_prefix(stream::iter(chunks).boxed(), &pool(), PrefixBoundary::json_lines())
|
||||
.await
|
||||
.expect("complete first record fits the inference sample");
|
||||
let schema = super::super::json_lines_schema::infer_schema(&prefix.bytes, DEFAULT_SCHEMA_INFER_MAX_RECORD)
|
||||
.expect("a complete JSON Lines object must not produce a truncated schema record");
|
||||
assert_eq!(prefix.bytes, first, "the incomplete second record is outside the sample");
|
||||
assert_eq!(schema.fields().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_json_prefix_empty_and_complete_record_controls() {
|
||||
for input in [b"".as_slice(), b"{\"a\":1}\n".as_slice(), b"{\"a\":1}".as_slice()] {
|
||||
let prefix = read_schema_prefix(
|
||||
stream::iter([Ok(Bytes::copy_from_slice(input))]).boxed(),
|
||||
&pool(),
|
||||
PrefixBoundary::json_lines(),
|
||||
)
|
||||
.await
|
||||
.expect("short complete input is accepted");
|
||||
let schema = super::super::json_lines_schema::infer_schema(&prefix.bytes, DEFAULT_SCHEMA_INFER_MAX_RECORD)
|
||||
.expect("short JSON Lines schema");
|
||||
assert_eq!(schema.fields().len(), usize::from(!input.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_schema_prefix_rejects_an_incomplete_first_record() {
|
||||
for json in [true, false] {
|
||||
let bytes = if json {
|
||||
format!("{{\"payload\":\"{}\"}}\n", "x".repeat(MAX_SCHEMA_PREFIX_BYTES))
|
||||
} else {
|
||||
format!("\"{}\"\n", "x".repeat(MAX_SCHEMA_PREFIX_BYTES))
|
||||
};
|
||||
let boundary = if json {
|
||||
PrefixBoundary::json_lines()
|
||||
} else {
|
||||
PrefixBoundary::csv(&CsvFormat::default())
|
||||
};
|
||||
let error = read_schema_prefix(stream::iter([Ok(Bytes::from(bytes))]).boxed(), &pool(), boundary)
|
||||
.await
|
||||
.err()
|
||||
.expect("a partial first record cannot provide an inference sample");
|
||||
assert!(error.to_string().contains("4 MiB prefix"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_schema_prefix_stops_polling_after_complete_sample() {
|
||||
let polls = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&polls);
|
||||
let chunks = stream::iter([
|
||||
Ok(Bytes::from("{\"a\":1}\n".repeat(DEFAULT_SCHEMA_INFER_MAX_RECORD))),
|
||||
Err(ObjectStoreError::Generic {
|
||||
store: "unread tail",
|
||||
source: Box::new(std::io::Error::other("tail must not be read")),
|
||||
}),
|
||||
])
|
||||
.inspect(move |_| {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.boxed();
|
||||
let prefix = read_schema_prefix(chunks, &pool(), PrefixBoundary::json_lines())
|
||||
.await
|
||||
.expect("bounded prefix");
|
||||
assert_eq!(polls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
super::super::json_lines_schema::infer_schema(&prefix.bytes, DEFAULT_SCHEMA_INFER_MAX_RECORD)
|
||||
.expect("schema")
|
||||
.fields()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_schema_prefix_preserves_csv_quotes_bom_and_chunk_boundaries() {
|
||||
let format = CsvFormat::default().with_has_header(true).with_comment(Some(b'#'));
|
||||
// All the commas below belong to one quoted header, not extra columns.
|
||||
let input = format!("\u{feff}#comment\n\"{}\",second\nvalue,2\n", "comma,".repeat(1025));
|
||||
let chunks = input
|
||||
.into_bytes()
|
||||
.into_iter()
|
||||
.map(|byte| Ok(Bytes::from(vec![byte])))
|
||||
.collect::<Vec<_>>();
|
||||
let prefix = read_schema_prefix(stream::iter(chunks).boxed(), &pool(), PrefixBoundary::csv(&format))
|
||||
.await
|
||||
.expect("quoted header");
|
||||
let schema = infer_csv_schema(&prefix.bytes, &format).expect("schema");
|
||||
assert_eq!(schema.fields().len(), 2);
|
||||
assert_eq!(schema.field(1).name(), "second");
|
||||
assert!(!prefix.bytes.ends_with(b"value,2\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_csv_bom_prefix_waits_for_data_or_transport_eof() {
|
||||
let format = CsvFormat::default().with_has_header(true);
|
||||
let chunks = stream::iter([
|
||||
Ok(Bytes::from_static(b"\xef\xbb\xbf")),
|
||||
Ok(Bytes::new()),
|
||||
Ok(Bytes::from_static(b"first,second\n")),
|
||||
Err(ObjectStoreError::Generic {
|
||||
store: "unread tail",
|
||||
source: Box::new(std::io::Error::other("tail must not be read")),
|
||||
}),
|
||||
])
|
||||
.boxed();
|
||||
let prefix = read_schema_prefix(chunks, &pool(), PrefixBoundary::csv(&format))
|
||||
.await
|
||||
.expect("BOM-only chunks must not signal EOF");
|
||||
assert_eq!(prefix.bytes, b"\xef\xbb\xbffirst,second\n");
|
||||
let schema = infer_csv_schema(&prefix.bytes, &format).expect("header schema");
|
||||
assert_eq!(schema.fields().len(), 2);
|
||||
assert_eq!(schema.field(0).name(), "first");
|
||||
assert_eq!(schema.field(1).name(), "second");
|
||||
|
||||
let prefix = read_schema_prefix(
|
||||
stream::iter([Ok(Bytes::from_static(b"\xef\xbb\xbf"))]).boxed(),
|
||||
&pool(),
|
||||
PrefixBoundary::csv(&format),
|
||||
)
|
||||
.await
|
||||
.expect("a BOM-only object ends at real transport EOF");
|
||||
assert_eq!(prefix.bytes, b"\xef\xbb\xbf");
|
||||
assert!(
|
||||
infer_csv_schema(&prefix.bytes, &format)
|
||||
.expect("empty schema")
|
||||
.fields()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_schema_prefix_counts_nonblank_utf8_lines_across_chunks() {
|
||||
let input = format!("\u{2003}\n{}", "{\"a\":1}\n".repeat(DEFAULT_SCHEMA_INFER_MAX_RECORD));
|
||||
let expected = input.len();
|
||||
let mut chunks = input
|
||||
.into_bytes()
|
||||
.into_iter()
|
||||
.map(|byte| Ok(Bytes::from(vec![byte])))
|
||||
.collect::<Vec<_>>();
|
||||
chunks.push(Err(ObjectStoreError::Generic {
|
||||
store: "unread tail",
|
||||
source: Box::new(std::io::Error::other("tail")),
|
||||
}));
|
||||
let prefix = read_schema_prefix(stream::iter(chunks).boxed(), &pool(), PrefixBoundary::json_lines())
|
||||
.await
|
||||
.expect("complete records");
|
||||
assert_eq!(prefix.bytes.len(), expected);
|
||||
}
|
||||
}
|
||||
@@ -64,10 +64,6 @@ impl MetadataProvider {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_column_count(&self) -> usize {
|
||||
self.provider.schema().fields().len()
|
||||
}
|
||||
|
||||
fn build_table_handle(&self) -> datafusion::common::Result<TableHandle> {
|
||||
self.current_session_table_provider.build_table_handle(self.provider.clone())
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ use rustfs_s3select_api::{
|
||||
|
||||
use super::dialect::RustFsDialect;
|
||||
|
||||
pub(crate) const MAX_SQL_EXPRESSION_BYTES: usize = 256 * 1024;
|
||||
|
||||
pub type Result<T, E = ParserError> = std::result::Result<T, E>;
|
||||
|
||||
// Use `Parser::expected` instead, if possible
|
||||
@@ -56,12 +54,7 @@ impl<'a> ExtParser<'a> {
|
||||
/// Parse the specified tokens with dialect
|
||||
fn new_with_dialect(sql: &str, dialect: &'a dyn Dialect) -> Result<Self> {
|
||||
let mut tokenizer = Tokenizer::new(dialect, sql);
|
||||
// Bound token allocation before the full recursive SQL parser runs.
|
||||
if sql.len() > MAX_SQL_EXPRESSION_BYTES {
|
||||
return parser_err!("S3 Select expression exceeds 256 KiB");
|
||||
}
|
||||
let mut tokens = tokenizer.tokenize()?;
|
||||
validate_projection_width(&tokens)?;
|
||||
rewrite_source_object_wildcards(&mut tokens);
|
||||
Ok(ExtParser {
|
||||
parser: Parser::new(dialect).with_tokens(tokens),
|
||||
@@ -113,41 +106,6 @@ impl<'a> ExtParser<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_projection_width(tokens: &[Token]) -> Result<()> {
|
||||
let mut depth = 0usize;
|
||||
let mut projections: Vec<(usize, usize)> = Vec::new();
|
||||
for token in tokens {
|
||||
match token {
|
||||
Token::Word(word) if word.keyword == Keyword::SELECT => projections.push((depth, 1)),
|
||||
Token::Word(word)
|
||||
if word.keyword == Keyword::FROM && projections.last().is_some_and(|(level, _)| *level == depth) =>
|
||||
{
|
||||
projections.pop();
|
||||
}
|
||||
Token::LParen | Token::LBracket => depth += 1,
|
||||
Token::RParen | Token::RBracket => {
|
||||
depth = depth.saturating_sub(1);
|
||||
while projections.last().is_some_and(|(level, _)| *level > depth) {
|
||||
projections.pop();
|
||||
}
|
||||
}
|
||||
Token::Comma => {
|
||||
if let Some((level, columns)) = projections.last_mut()
|
||||
&& *level == depth
|
||||
{
|
||||
*columns += 1;
|
||||
if *columns > rustfs_s3select_api::query::session::MAX_SELECT_COLUMNS {
|
||||
return parser_err!("S3 Select projection exceeds 1024 columns");
|
||||
}
|
||||
}
|
||||
}
|
||||
Token::SemiColon => projections.clear(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rewrite_source_object_wildcards(tokens: &mut [Token]) {
|
||||
let mut paren_depth = 0usize;
|
||||
let mut in_from = false;
|
||||
@@ -486,31 +444,6 @@ mod tests {
|
||||
assert!(memory_size < 10000, "Parsed statements should not use excessive memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_projection_preflight_respects_quoted_commas_and_nested_expressions() {
|
||||
let projection = (0..1024)
|
||||
.map(|index| format!("COALESCE('a,b', 'c') AS c{index}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
assert!(ExtParser::parse_sql(&format!("SELECT {projection} FROM S3Object")).is_ok());
|
||||
let error = ExtParser::parse_sql(&format!("SELECT {projection}, 1 FROM S3Object")).expect_err("column limit");
|
||||
assert!(error.to_string().contains("1024 columns"));
|
||||
let nested = format!("SELECT (SELECT 1), {projection} FROM S3Object");
|
||||
assert!(
|
||||
ExtParser::parse_sql(&nested)
|
||||
.expect_err("nested SELECT cannot reset outer width")
|
||||
.to_string()
|
||||
.contains("1024 columns")
|
||||
);
|
||||
let long_literal = "x".repeat(256 * 1024);
|
||||
assert!(
|
||||
ExtParser::parse_sql(&format!("SELECT '{long_literal}' FROM S3Object"))
|
||||
.expect_err("byte limit")
|
||||
.to_string()
|
||||
.contains("256 KiB")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_parser_large_query() {
|
||||
// Test with a reasonably large query
|
||||
|
||||
@@ -90,28 +90,6 @@ impl<'a, S: ContextProviderExtension + Send + Sync + 'a> SqlPlanner<'a, S> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_expanded_projection_width(statement: &ExtStatement, source_columns: usize) -> QueryResult<()> {
|
||||
let ExtStatement::SqlStatement(statement) = statement;
|
||||
let Statement::Query(query) = statement.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let SetExpr::Select(select) = query.body.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let width = select.projection.iter().try_fold(0usize, |width, item| {
|
||||
let columns = if matches!(item, SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(..)) {
|
||||
source_columns
|
||||
} else {
|
||||
1
|
||||
};
|
||||
width.checked_add(columns)
|
||||
});
|
||||
if width.is_none_or(|width| width > rustfs_s3select_api::query::session::MAX_SELECT_COLUMNS) {
|
||||
return Err(unsupported_structure("S3 Select expanded projection exceeds 1024 columns"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn classify_planner_error(error: datafusion::common::DataFusionError) -> QueryError {
|
||||
if matches!(
|
||||
&error,
|
||||
@@ -436,9 +414,6 @@ fn implicit_source_alias(source_path: &[JsonPathSegment]) -> Ident {
|
||||
}
|
||||
|
||||
fn prepare_select(select: &mut Select) -> QueryResult<JsonSource> {
|
||||
if select.projection.len() > rustfs_s3select_api::query::session::MAX_SELECT_COLUMNS {
|
||||
return Err(unsupported_structure("S3 Select projection exceeds 1024 columns"));
|
||||
}
|
||||
if !select.optimizer_hints.is_empty()
|
||||
|| select.distinct.is_some()
|
||||
|| select.select_modifiers.is_some()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user