mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 23:26:53 +00:00
fix(select): pin object snapshot for query lifetime (#5835)
This commit is contained in:
@@ -181,6 +181,7 @@ path-absolutize = { workspace = true }
|
||||
rmp.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
tokio-util = { workspace = true, features = ["io", "compat"] }
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
base64 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
|
||||
@@ -414,7 +414,10 @@ pub mod object {
|
||||
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
||||
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
};
|
||||
pub use crate::store::PreparedGetObjectReader;
|
||||
pub use crate::store::{
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
SnapshotConsistencyError,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod rebalance {
|
||||
@@ -449,6 +452,11 @@ pub mod rpc {
|
||||
|
||||
pub mod set_disk {
|
||||
pub use crate::set_disk::{DEFAULT_READ_BUFFER_SIZE, SetDisks, get_lock_acquire_timeout, is_valid_storage_class};
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
}
|
||||
}
|
||||
|
||||
pub mod store_list {
|
||||
|
||||
@@ -1215,6 +1215,98 @@ async fn init_storage_disks_with_errors(
|
||||
(disks, errs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn make_local_two_set_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||
make_local_two_set_sets_with_ctx(bootstrap_ctx()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn make_local_two_set_sets_with_ctx(ctx: Arc<InstanceContext>) -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use rustfs_lock::client::local::LocalClient;
|
||||
|
||||
let format = FormatV3::new(2, 2);
|
||||
let mut temp_dirs = Vec::new();
|
||||
let mut all_endpoints = Vec::new();
|
||||
let mut disk_sets = Vec::new();
|
||||
|
||||
for set_index in 0..2 {
|
||||
let mut endpoints = Vec::new();
|
||||
let mut disks = Vec::new();
|
||||
for disk_index in 0..2 {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("tempdir path should be utf8"))
|
||||
.expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(set_index);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("disk should be created");
|
||||
let mut disk_format = format.clone();
|
||||
disk_format.erasure.this = format.erasure.sets[set_index][disk_index];
|
||||
save_format_file(&Some(disk.clone()), &Some(disk_format))
|
||||
.await
|
||||
.expect("format should be saved");
|
||||
temp_dirs.push(temp_dir);
|
||||
all_endpoints.push(endpoint.clone());
|
||||
endpoints.push(endpoint);
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
let lockers = (0..2)
|
||||
.map(|_| {
|
||||
Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::Enabled(Arc::new(
|
||||
rustfs_lock::FastObjectLockManager::new(),
|
||||
))))) as Arc<dyn rustfs_lock::LockClient>
|
||||
})
|
||||
.collect();
|
||||
disk_sets.push(
|
||||
SetDisks::new_with_instance_ctx(
|
||||
"test-owner".to_string(),
|
||||
Arc::new(RwLock::new(disks)),
|
||||
2,
|
||||
1,
|
||||
set_index,
|
||||
0,
|
||||
endpoints,
|
||||
format.clone(),
|
||||
lockers,
|
||||
Arc::clone(&ctx),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
let sets = Arc::new(Sets {
|
||||
id: format.id,
|
||||
disk_set: disk_sets,
|
||||
pool_idx: 0,
|
||||
endpoints: PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 2,
|
||||
drives_per_set: 2,
|
||||
endpoints: Endpoints::from(all_endpoints),
|
||||
cmd_line: String::new(),
|
||||
platform: String::new(),
|
||||
},
|
||||
format,
|
||||
parity_count: 1,
|
||||
set_count: 2,
|
||||
set_drive_count: 2,
|
||||
default_parity_count: 1,
|
||||
distribution_algo: DistributionAlgoVersion::V1,
|
||||
exit_signal: None,
|
||||
ctx,
|
||||
});
|
||||
(temp_dirs, sets)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1373,81 +1465,6 @@ mod tests {
|
||||
assert_eq!(result, (Some(3), Some(1), Some(0)));
|
||||
}
|
||||
|
||||
async fn two_set_test_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||
let format = FormatV3::new(2, 2);
|
||||
let mut temp_dirs = Vec::new();
|
||||
let mut all_endpoints = Vec::new();
|
||||
let mut disk_sets = Vec::new();
|
||||
|
||||
for set_index in 0..2 {
|
||||
let mut endpoints = Vec::new();
|
||||
let mut disks = Vec::new();
|
||||
for disk_index in 0..2 {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("tempdir path should be utf8"))
|
||||
.expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(set_index);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("disk should be created");
|
||||
let mut disk_format = format.clone();
|
||||
disk_format.erasure.this = format.erasure.sets[set_index][disk_index];
|
||||
save_format_file(&Some(disk.clone()), &Some(disk_format))
|
||||
.await
|
||||
.expect("format should be saved");
|
||||
temp_dirs.push(temp_dir);
|
||||
all_endpoints.push(endpoint.clone());
|
||||
endpoints.push(endpoint);
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
disk_sets.push(
|
||||
SetDisks::new(
|
||||
"test-owner".to_string(),
|
||||
Arc::new(RwLock::new(disks)),
|
||||
2,
|
||||
1,
|
||||
set_index,
|
||||
0,
|
||||
endpoints,
|
||||
format.clone(),
|
||||
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
let sets = Arc::new(Sets {
|
||||
id: format.id,
|
||||
disk_set: disk_sets,
|
||||
pool_idx: 0,
|
||||
endpoints: PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 2,
|
||||
drives_per_set: 2,
|
||||
endpoints: Endpoints::from(all_endpoints),
|
||||
cmd_line: String::new(),
|
||||
platform: String::new(),
|
||||
},
|
||||
format,
|
||||
parity_count: 1,
|
||||
set_count: 2,
|
||||
set_drive_count: 2,
|
||||
default_parity_count: 1,
|
||||
distribution_algo: DistributionAlgoVersion::V1,
|
||||
exit_signal: None,
|
||||
ctx: bootstrap_ctx(),
|
||||
});
|
||||
(temp_dirs, sets)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_object_uses_explicit_set_scope() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
@@ -1497,7 +1514,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_surfaces_a_hard_error_from_any_set() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let (_temp_dirs, sets) = make_local_two_set_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
@@ -1546,7 +1563,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_keeps_a_missing_bucket_idempotent_across_sets() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let (_temp_dirs, sets) = make_local_two_set_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
@@ -1585,7 +1602,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_preserves_a_completely_missing_bucket_error() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let (_temp_dirs, sets) = make_local_two_set_sets().await;
|
||||
let bucket = format!("delete-prefix-missing-{}", Uuid::new_v4().simple());
|
||||
|
||||
let err = sets
|
||||
@@ -1605,7 +1622,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_fails_when_one_set_is_entirely_offline() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let (_temp_dirs, sets) = make_local_two_set_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
@@ -1652,7 +1669,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_format_heal_accepts_quorum_from_a_nonzero_set() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let (_temp_dirs, sets) = make_local_two_set_sets().await;
|
||||
|
||||
let (result, err) = sets.disk_set[1]
|
||||
.heal_format(false)
|
||||
@@ -1757,7 +1774,7 @@ mod tests {
|
||||
#[serial]
|
||||
async fn list_multipart_uploads_merges_all_sets_without_pagination_loss() {
|
||||
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let (_temp_dirs, sets) = make_local_two_set_sets().await;
|
||||
let bucket = format!("multipart-list-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
|
||||
@@ -208,6 +208,11 @@ impl InstanceContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_lock_manager_for_test(lock_manager: Arc<GlobalLockManager>) -> Self {
|
||||
Self::with_lock_manager(lock_manager)
|
||||
}
|
||||
|
||||
/// This instance's namespace lock manager.
|
||||
pub fn lock_manager(&self) -> Arc<GlobalLockManager> {
|
||||
self.lock_manager.clone()
|
||||
|
||||
@@ -4651,7 +4651,7 @@ pub(in crate::set_disk) mod cleanup_fault_injection {
|
||||
/// unobserved object records nothing, keeping the registry bounded, and each
|
||||
/// [`CallCounterScope`] clears only its own object's counts on drop.
|
||||
#[cfg(test)]
|
||||
pub(in crate::set_disk) mod disk_call_counters {
|
||||
pub(crate) mod disk_call_counters {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
|
||||
@@ -692,6 +692,8 @@ const DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: bool = true;
|
||||
static OBJECT_LOCK_DIAG_ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
mod core;
|
||||
#[cfg(test)]
|
||||
pub(crate) use core::io_primitives::disk_call_counters;
|
||||
mod ctx;
|
||||
mod metadata;
|
||||
mod ops;
|
||||
@@ -702,8 +704,8 @@ pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCl
|
||||
pub(crate) use ops::object::body_cache_plaintext_len;
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::object::cleanup_rejected_transition_upload_durably;
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
mod read;
|
||||
mod replication;
|
||||
pub(crate) mod shard_source;
|
||||
@@ -731,6 +733,10 @@ impl PreparedGetObjectMetadata {
|
||||
.take()
|
||||
.expect("prepared GET metadata ObjectInfo must be consumed exactly once")
|
||||
}
|
||||
|
||||
pub(crate) fn read_semantics_identity(&self) -> [u8; 32] {
|
||||
SetDisks::file_info_quorum_hash(&self.fi)
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
@@ -2553,6 +2559,53 @@ impl SetDisks {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
async fn acquire_write_lock_diag_with_pending_hook(
|
||||
&self,
|
||||
op: &'static str,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
on_pending: impl FnOnce(),
|
||||
) -> Result<ObjectLockDiagGuard> {
|
||||
crate::hp_guard!("SetDisks::acquire_write_lock");
|
||||
let diag_enabled = is_object_lock_diag_enabled();
|
||||
let ns_lock = self.new_ns_lock(bucket, object).await?;
|
||||
let acquire_start = Instant::now();
|
||||
let acquire = ns_lock.get_write_lock(get_lock_acquire_timeout());
|
||||
tokio::pin!(acquire);
|
||||
let mut on_pending = Some(on_pending);
|
||||
let guard = futures::future::poll_fn(|cx| match std::future::Future::poll(acquire.as_mut(), cx) {
|
||||
std::task::Poll::Pending => {
|
||||
if let Some(on_pending) = on_pending.take() {
|
||||
on_pending();
|
||||
}
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
std::task::Poll::Ready(result) => std::task::Poll::Ready(result),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| self.map_namespace_lock_error(bucket, object, "write", e))?;
|
||||
let owner = diag_enabled.then(|| ns_lock.owner().to_string());
|
||||
self.log_object_lock_acquire_if_slow(
|
||||
op,
|
||||
bucket,
|
||||
object,
|
||||
"write",
|
||||
owner.as_deref(),
|
||||
acquire_start.elapsed(),
|
||||
diag_enabled,
|
||||
);
|
||||
Ok(ObjectLockDiagGuard::new(
|
||||
guard,
|
||||
diag_enabled,
|
||||
op,
|
||||
diag_enabled.then(|| bucket.to_string()),
|
||||
diag_enabled.then(|| object.to_string()),
|
||||
owner,
|
||||
"write",
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn log_object_lock_acquire_if_slow(
|
||||
&self,
|
||||
|
||||
@@ -1312,7 +1312,7 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
if !opts.no_lock && object_lock_guard.is_none() {
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_put_object_commit(bucket, object, PutObjectCommitPause::BeforeNamespace).await;
|
||||
if let Some(expected_incarnation_id) = opts.expected_bucket_incarnation_id
|
||||
&& opts.bucket_lifecycle_lock_fence.is_none()
|
||||
@@ -1324,9 +1324,21 @@ impl SetDisks {
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
object_lock_guard = Some(self.acquire_write_lock_diag("put_object_commit", bucket, object).await?);
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
{
|
||||
object_lock_guard = Some(
|
||||
self.acquire_write_lock_diag_with_pending_hook("put_object_commit", bucket, object, || {
|
||||
notify_put_object_commit_namespace_pending(bucket, object);
|
||||
})
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
#[cfg(not(any(test, feature = "test-util")))]
|
||||
{
|
||||
object_lock_guard = Some(self.acquire_write_lock_diag("put_object_commit", bucket, object).await?);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_put_object_commit(bucket, object, PutObjectCommitPause::AfterNamespace).await;
|
||||
|
||||
if deferred_data_movement_precondition && let Some(err) = self.check_write_precondition(bucket, object, opts).await {
|
||||
@@ -2575,41 +2587,43 @@ fn remote_version_state_writer_enabled_for(requested: bool, fleet_confirmed: boo
|
||||
requested && fleet_confirmed && fleet_proof_valid
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PutObjectCommitPause {
|
||||
pub enum PutObjectCommitPause {
|
||||
BeforeNamespace,
|
||||
AfterNamespace,
|
||||
BeforeMetadata,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
struct PutObjectCommitBarrierState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
pause: PutObjectCommitPause,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
namespace_pending: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct PutObjectCommitBarrier {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub struct PutObjectCommitBarrier {
|
||||
state: Arc<PutObjectCommitBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
static PUT_OBJECT_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Vec<Arc<PutObjectCommitBarrierState>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl PutObjectCommitBarrier {
|
||||
pub(crate) fn install(bucket: &str, object: &str, pause: PutObjectCommitPause) -> Self {
|
||||
pub fn install(bucket: &str, object: &str, pause: PutObjectCommitPause) -> Self {
|
||||
let state = Arc::new(PutObjectCommitBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
pause,
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
namespace_pending: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = PUT_OBJECT_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
@@ -2626,18 +2640,27 @@ impl PutObjectCommitBarrier {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
pub async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("put object should reach the deterministic commit barrier");
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
pub fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
|
||||
pub async fn release_and_wait_until_namespace_pending(&self) {
|
||||
assert_eq!(self.state.pause, PutObjectCommitPause::BeforeNamespace);
|
||||
let namespace_pending = self.state.namespace_pending.notified();
|
||||
self.release();
|
||||
tokio::time::timeout(Duration::from_secs(5), namespace_pending)
|
||||
.await
|
||||
.expect("put object should wait for the namespace lock after leaving the commit barrier");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl Drop for PutObjectCommitBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
@@ -2649,7 +2672,7 @@ impl Drop for PutObjectCommitBarrier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
async fn pause_put_object_commit(bucket: &str, object: &str, pause: PutObjectCommitPause) {
|
||||
let barrier = PUT_OBJECT_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
@@ -2664,6 +2687,22 @@ async fn pause_put_object_commit(bucket: &str, object: &str, pause: PutObjectCom
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn notify_put_object_commit_namespace_pending(bucket: &str, object: &str) {
|
||||
let barrier = PUT_OBJECT_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("put object commit barrier mutex should not poison")
|
||||
.iter()
|
||||
.find(|barrier| {
|
||||
barrier.bucket == bucket && barrier.object == object && barrier.pause == PutObjectCommitPause::BeforeNamespace
|
||||
})
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.namespace_pending.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DeleteObjectCommitBarrierState {
|
||||
bucket: String,
|
||||
@@ -4414,7 +4453,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
self.invalidate_get_object_metadata_cache(bucket, object).await;
|
||||
|
||||
// Guard lock for metadata update
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_put_object_commit(bucket, object, PutObjectCommitPause::BeforeMetadata).await;
|
||||
let _lock_guard = if !opts.no_lock {
|
||||
Some(self.acquire_write_lock_diag("put_object_metadata", bucket, object).await?)
|
||||
|
||||
@@ -152,7 +152,10 @@ mod list;
|
||||
pub(crate) mod list_objects;
|
||||
mod multipart;
|
||||
mod object;
|
||||
pub use object::PreparedGetObjectReader;
|
||||
pub use object::{
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
SnapshotConsistencyError,
|
||||
};
|
||||
mod peer;
|
||||
mod rebalance;
|
||||
pub(crate) mod utils;
|
||||
|
||||
+1196
-15
File diff suppressed because it is too large
Load Diff
@@ -77,11 +77,12 @@ parking_lot.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
tokio-util = { workspace = true, features = ["io", "compat"] }
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
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]
|
||||
|
||||
@@ -15,22 +15,23 @@
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use datafusion::{common::DataFusionError, sql::sqlparser::parser::ParserError};
|
||||
use std::fmt::Display;
|
||||
use std::{error::Error as StdError, fmt::Display};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod object_store;
|
||||
pub mod query;
|
||||
pub mod server;
|
||||
mod storage_api;
|
||||
pub use storage_api::SelectObjectSnapshot;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
pub type QueryResult<T> = Result<T, QueryError>;
|
||||
pub(crate) use storage_api::crate_boundary::{
|
||||
SELECT_DEFAULT_READ_BUFFER_SIZE, SelectGetObjectReader, SelectObjectInfo, SelectObjectOptions, SelectStorageError,
|
||||
SelectStore, resolve_select_object_store_handle, select_is_err_bucket_not_found, select_is_err_object_not_found,
|
||||
select_is_err_version_not_found,
|
||||
PrepareSelectObjectSnapshotError, SELECT_DEFAULT_READ_BUFFER_SIZE, SelectGetObjectReader, SelectObjectOptions,
|
||||
SelectObjectSnapshotReadError, SelectStorageError, SelectStore, SnapshotConsistencyError, resolve_select_object_store_handle,
|
||||
select_is_err_bucket_not_found, select_is_err_object_not_found, select_is_err_version_not_found,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -79,24 +80,24 @@ pub enum S3SelectPolicyError {
|
||||
QueryTimeout { seconds: u64 },
|
||||
}
|
||||
|
||||
impl S3SelectPolicyError {
|
||||
fn from_error<'a>(mut err: &'a (dyn std::error::Error + 'static)) -> Option<&'a Self> {
|
||||
impl QueryError {
|
||||
fn source_error<T: StdError + 'static>(&self) -> Option<&T> {
|
||||
let mut err: &(dyn StdError + 'static) = self;
|
||||
for _ in 0..16 {
|
||||
if let Some(policy_error) = err.downcast_ref::<Self>() {
|
||||
return Some(policy_error);
|
||||
if let Some(source) = err.downcast_ref::<T>() {
|
||||
return Some(source);
|
||||
}
|
||||
err = err.source()?;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl QueryError {
|
||||
pub fn is_snapshot_consistency_error(&self) -> bool {
|
||||
self.source_error::<SnapshotConsistencyError>().is_some()
|
||||
}
|
||||
|
||||
pub fn s3_select_policy_error(&self) -> Option<&S3SelectPolicyError> {
|
||||
match self {
|
||||
Self::Datafusion { source } => S3SelectPolicyError::from_error(source.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
self.source_error()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +231,17 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_consistency_error_is_recoverable_without_string_matching() {
|
||||
let err = QueryError::Datafusion {
|
||||
source: Box::new(DataFusionError::External(Box::new(SelectObjectSnapshotReadError::Consistency(
|
||||
SnapshotConsistencyError::LockLost,
|
||||
)))),
|
||||
};
|
||||
|
||||
assert!(err.is_snapshot_consistency_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_error_from_parser_error() {
|
||||
let parser_error = ParserError::ParserError("syntax error".to_string());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ use super::{
|
||||
Query,
|
||||
execution::{Output, QueryStateMachine},
|
||||
logical_planner::Plan,
|
||||
session::QueryAdmission,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
@@ -32,6 +33,14 @@ pub trait QueryDispatcher: Send + Sync {
|
||||
|
||||
async fn execute_query(&self, query: &Query) -> QueryResult<Output>;
|
||||
|
||||
fn try_reserve_query(&self) -> QueryResult<QueryAdmission> {
|
||||
Ok(QueryAdmission::unmanaged())
|
||||
}
|
||||
|
||||
async fn execute_query_admitted(&self, query: &Query, _admission: QueryAdmission) -> QueryResult<Output> {
|
||||
self.execute_query(query).await
|
||||
}
|
||||
|
||||
async fn build_logical_plan(&self, query_state_machine: Arc<QueryStateMachine>) -> QueryResult<Option<Plan>>;
|
||||
|
||||
async fn execute_logical_plan(&self, logical_plan: Plan, query_state_machine: Arc<QueryStateMachine>) -> QueryResult<Output>;
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
use s3s::dto::SelectObjectContentInput;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::SelectObjectSnapshot;
|
||||
|
||||
pub mod analyzer;
|
||||
pub mod ast;
|
||||
pub mod dispatcher;
|
||||
@@ -37,12 +39,26 @@ pub struct Context {
|
||||
pub struct Query {
|
||||
context: Context,
|
||||
content: String,
|
||||
snapshot: Option<Arc<SelectObjectSnapshot>>,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
#[inline(always)]
|
||||
pub fn new(context: Context, content: String) -> Self {
|
||||
Self { context, content }
|
||||
Self {
|
||||
context,
|
||||
content,
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn new_with_snapshot(context: Context, content: String, snapshot: Arc<SelectObjectSnapshot>) -> Self {
|
||||
Self {
|
||||
context,
|
||||
content,
|
||||
snapshot: Some(snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn context(&self) -> &Context {
|
||||
@@ -52,4 +68,8 @@ impl Query {
|
||||
pub fn content(&self) -> &str {
|
||||
self.content.as_str()
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Option<&Arc<SelectObjectSnapshot>> {
|
||||
self.snapshot.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,16 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::SelectObjectSnapshot;
|
||||
use crate::query::Context;
|
||||
use crate::{QueryError, QueryResult, SelectStore, object_store::EcObjectStore};
|
||||
use crate::{QueryError, QueryResult, object_store::EcObjectStore};
|
||||
use datafusion::{
|
||||
arrow::{
|
||||
array::{Int32Array, StringArray},
|
||||
datatypes::{DataType, Field, Schema},
|
||||
record_batch::RecordBatch,
|
||||
},
|
||||
common::DataFusionError,
|
||||
execution::{SessionStateBuilder, config::SessionConfig, context::SessionState, runtime_env::RuntimeEnvBuilder},
|
||||
object_store::{ObjectStore, ObjectStoreExt, memory::InMemory, path::Path},
|
||||
parquet::arrow::ArrowWriter,
|
||||
@@ -39,6 +41,28 @@ use tracing::error;
|
||||
|
||||
pub type QueryExecutionGuard = Arc<OwnedSemaphorePermit>;
|
||||
|
||||
/// A one-shot query admission reservation handed from the request boundary to
|
||||
/// the dispatcher that owns the corresponding concurrency semaphore.
|
||||
pub struct QueryAdmission {
|
||||
query_guard: Option<QueryExecutionGuard>,
|
||||
}
|
||||
|
||||
impl QueryAdmission {
|
||||
pub fn new(query_guard: QueryExecutionGuard) -> Self {
|
||||
Self {
|
||||
query_guard: Some(query_guard),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_query_guard(mut self) -> Option<QueryExecutionGuard> {
|
||||
self.query_guard.take()
|
||||
}
|
||||
|
||||
pub(crate) fn unmanaged() -> Self {
|
||||
Self { query_guard: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct QueryExecutionOwner {
|
||||
identity: Arc<()>,
|
||||
@@ -300,30 +324,30 @@ impl SessionCtxFactory {
|
||||
query_tracker: QueryExecutionTracker,
|
||||
memory_limit_bytes: usize,
|
||||
) -> QueryResult<SessionCtx> {
|
||||
self.create_session_ctx_inner(context, Some(query_tracker), None, memory_limit_bytes)
|
||||
self.create_session_ctx_inner(context, None, Some(query_tracker), memory_limit_bytes)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn create_session_ctx_with_tracker_and_store(
|
||||
pub async fn create_session_ctx_with_snapshot_and_tracker_and_memory_limit(
|
||||
&self,
|
||||
context: &Context,
|
||||
snapshot: Arc<SelectObjectSnapshot>,
|
||||
query_tracker: QueryExecutionTracker,
|
||||
store: Arc<SelectStore>,
|
||||
memory_limit_bytes: usize,
|
||||
) -> QueryResult<SessionCtx> {
|
||||
self.create_session_ctx_inner(context, Some(query_tracker), Some(store), DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES)
|
||||
self.create_session_ctx_inner(context, Some(snapshot), Some(query_tracker), memory_limit_bytes)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_session_ctx_inner(
|
||||
&self,
|
||||
context: &Context,
|
||||
snapshot: Option<Arc<SelectObjectSnapshot>>,
|
||||
query_tracker: Option<QueryExecutionTracker>,
|
||||
store: Option<Arc<SelectStore>>,
|
||||
memory_limit_bytes: usize,
|
||||
) -> QueryResult<SessionCtx> {
|
||||
let df_session_ctx = self
|
||||
.build_df_session_context(context, query_tracker.clone(), store, memory_limit_bytes)
|
||||
.build_df_session_context(context, snapshot, query_tracker.clone(), memory_limit_bytes)
|
||||
.await?;
|
||||
|
||||
Ok(SessionCtx {
|
||||
@@ -336,8 +360,8 @@ impl SessionCtxFactory {
|
||||
async fn build_df_session_context(
|
||||
&self,
|
||||
context: &Context,
|
||||
snapshot: Option<Arc<SelectObjectSnapshot>>,
|
||||
query_tracker: Option<QueryExecutionTracker>,
|
||||
store: Option<Arc<SelectStore>>,
|
||||
memory_limit_bytes: usize,
|
||||
) -> QueryResult<SessionContext> {
|
||||
let path = format!("s3://{}", context.input.bucket);
|
||||
@@ -416,11 +440,13 @@ impl SessionCtxFactory {
|
||||
} else {
|
||||
let store: EcObjectStore = match query_tracker {
|
||||
Some(query_tracker) => {
|
||||
EcObjectStore::new_with_query_tracker(context.input.clone(), memory_pool, query_tracker, store)
|
||||
EcObjectStore::new_with_query_tracker(context.input.clone(), memory_pool, query_tracker, snapshot)
|
||||
}
|
||||
None => EcObjectStore::new_with_memory_pool(context.input.clone(), memory_pool),
|
||||
None => EcObjectStore::new_with_memory_pool(context.input.clone(), memory_pool, snapshot),
|
||||
}
|
||||
.map_err(|_| QueryError::NotImplemented { err: String::new() })?;
|
||||
.map_err(|err| QueryError::Datafusion {
|
||||
source: Box::new(DataFusionError::External(Box::new(err))),
|
||||
})?;
|
||||
df_session_state.with_object_store(&store_url, Arc::new(store)).build()
|
||||
};
|
||||
|
||||
@@ -498,6 +524,7 @@ mod tests {
|
||||
},
|
||||
execution::memory_pool::MemoryLimit,
|
||||
};
|
||||
use http::HeaderMap;
|
||||
use s3s::dto::{
|
||||
CSVInput, CSVOutput, ExpressionType, InputSerialization, JSONInput, OutputSerialization, ParquetInput, ScanRange,
|
||||
SelectObjectContentInput, SelectObjectContentRequest,
|
||||
@@ -531,6 +558,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_test_snapshot(context: &Context) -> Arc<SelectObjectSnapshot> {
|
||||
let env = crate::storage_api::select_test_ecstore_env().await;
|
||||
Arc::new(
|
||||
env.ecstore
|
||||
.prepare_select_object_snapshot(&context.input.bucket, &context.input.key, &HeaderMap::new(), &Default::default())
|
||||
.await
|
||||
.expect("prepare SelectObjectContent snapshot"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_factory_fields_remain_source_compatible() {
|
||||
let factory = SessionCtxFactory {
|
||||
@@ -679,10 +716,57 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn production_session_preserves_lazy_snapshot_entry() {
|
||||
let _env = crate::storage_api::select_test_ecstore_env().await;
|
||||
let session = SessionCtxFactory::new(false)
|
||||
.create_session_ctx(&test_context())
|
||||
.await
|
||||
.expect("legacy production session should install a lazy object store");
|
||||
|
||||
assert_eq!(session.inner().config().target_partitions(), SessionConfig::new().target_partitions());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn legacy_tracked_production_session_preserves_lazy_snapshot_entry() {
|
||||
let _env = crate::storage_api::select_test_ecstore_env().await;
|
||||
let permit = Arc::new(tokio::sync::Semaphore::new(1))
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("query permit should be available");
|
||||
let tracker = QueryExecutionTracker::new(
|
||||
&QueryExecutionOwner::new(),
|
||||
Arc::new(permit),
|
||||
Instant::now() + std::time::Duration::from_secs(300),
|
||||
300,
|
||||
);
|
||||
let session = SessionCtxFactory::new(false)
|
||||
.create_session_ctx_with_tracker_and_memory_limit(
|
||||
&test_context(),
|
||||
tracker.clone(),
|
||||
DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES,
|
||||
)
|
||||
.await
|
||||
.expect("legacy tracked session should install a lazy object store");
|
||||
|
||||
assert!(session.is_bound_to(&tracker));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial]
|
||||
async fn session_factory_propagates_query_guard_to_ec_store() {
|
||||
let env = crate::storage_api::select_test_ecstore_env().await;
|
||||
let mut context = test_context();
|
||||
Arc::make_mut(&mut context.input).bucket = "s3select-query-guard-snapshot".to_string();
|
||||
env.make_bucket(&context.input.bucket, false).await;
|
||||
let mut reader = SelectPutObjReader::from_vec(b"id,name\n1,Alice\n".to_vec());
|
||||
env.ecstore
|
||||
.put_object(&context.input.bucket, &context.input.key, &mut reader, &Default::default())
|
||||
.await
|
||||
.expect("put query guard fixture");
|
||||
let snapshot = prepare_test_snapshot(&context).await;
|
||||
|
||||
let admission = Arc::new(tokio::sync::Semaphore::new(1));
|
||||
let permit = Arc::clone(&admission)
|
||||
@@ -697,7 +781,12 @@ mod tests {
|
||||
300,
|
||||
);
|
||||
let session = SessionCtxFactory::new(false)
|
||||
.create_session_ctx_with_tracker_and_store(&test_context(), query_tracker, Arc::clone(&env.ecstore))
|
||||
.create_session_ctx_with_snapshot_and_tracker_and_memory_limit(
|
||||
&context,
|
||||
snapshot,
|
||||
query_tracker,
|
||||
DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES,
|
||||
)
|
||||
.await
|
||||
.expect("production session should be created with the query guard");
|
||||
|
||||
@@ -706,6 +795,51 @@ mod tests {
|
||||
assert_eq!(Arc::strong_count(&query_guard), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial]
|
||||
async fn session_factory_preserves_snapshot_binding_error_source() {
|
||||
let env = crate::storage_api::select_test_ecstore_env().await;
|
||||
let mut source_context = test_context();
|
||||
Arc::make_mut(&mut source_context.input).bucket = "s3select-session-snapshot-identity".to_string();
|
||||
Arc::make_mut(&mut source_context.input).key = "source.csv".to_string();
|
||||
env.make_bucket(&source_context.input.bucket, false).await;
|
||||
let mut reader = SelectPutObjReader::from_vec(b"source-marker\n".to_vec());
|
||||
env.ecstore
|
||||
.put_object(&source_context.input.bucket, &source_context.input.key, &mut reader, &Default::default())
|
||||
.await
|
||||
.expect("put snapshot identity fixture");
|
||||
let snapshot = prepare_test_snapshot(&source_context).await;
|
||||
|
||||
let mut target_context = source_context.clone();
|
||||
Arc::make_mut(&mut target_context.input).key = "different.csv".to_string();
|
||||
let permit = Arc::new(tokio::sync::Semaphore::new(1))
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("query permit should be available");
|
||||
let tracker = QueryExecutionTracker::new(
|
||||
&QueryExecutionOwner::new(),
|
||||
Arc::new(permit),
|
||||
Instant::now() + std::time::Duration::from_secs(300),
|
||||
300,
|
||||
);
|
||||
|
||||
let error = match SessionCtxFactory::new(false)
|
||||
.create_session_ctx_with_snapshot_and_tracker_and_memory_limit(
|
||||
&target_context,
|
||||
snapshot,
|
||||
tracker,
|
||||
DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("a session must reject a snapshot for a different object"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(error.is_snapshot_consistency_error());
|
||||
assert!(error.to_string().contains("snapshot consistency failure"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial]
|
||||
async fn scan_range_is_preserved_across_large_csv_partition_boundary() {
|
||||
@@ -721,6 +855,7 @@ mod tests {
|
||||
assert!(data.len() > 1024 * 1024);
|
||||
|
||||
let mut context = test_context();
|
||||
Arc::make_mut(&mut context.input).bucket = "s3select-scan-range-partition-snapshot".to_string();
|
||||
let selected_start = i64::try_from(SELECTED_ROW * ROW_WIDTH).expect("selected row offset should fit in i64");
|
||||
Arc::make_mut(&mut context.input).request.scan_range = Some(ScanRange {
|
||||
start: Some(selected_start),
|
||||
@@ -732,6 +867,7 @@ mod tests {
|
||||
.put_object(&context.input.bucket, &context.input.key, &mut reader, &Default::default())
|
||||
.await
|
||||
.expect("put large ScanRange CSV fixture");
|
||||
let snapshot = prepare_test_snapshot(&context).await;
|
||||
|
||||
let admission = Arc::new(tokio::sync::Semaphore::new(1));
|
||||
let permit = Arc::clone(&admission)
|
||||
@@ -746,7 +882,12 @@ mod tests {
|
||||
);
|
||||
let session = SessionCtxFactory::new(false)
|
||||
.with_target_partitions(2)
|
||||
.create_session_ctx_with_tracker_and_store(&context, query_tracker, Arc::clone(&env.ecstore))
|
||||
.create_session_ctx_with_snapshot_and_tracker_and_memory_limit(
|
||||
&context,
|
||||
snapshot,
|
||||
query_tracker,
|
||||
DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES,
|
||||
)
|
||||
.await
|
||||
.expect("create production ScanRange session");
|
||||
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::{
|
||||
Query,
|
||||
execution::{Output, QueryStateMachineRef},
|
||||
logical_planner::Plan,
|
||||
session::QueryAdmission,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,7 +45,16 @@ impl QueryHandle {
|
||||
|
||||
#[async_trait]
|
||||
pub trait DatabaseManagerSystem {
|
||||
fn try_reserve_query(&self) -> QueryResult<QueryAdmission> {
|
||||
Ok(QueryAdmission::unmanaged())
|
||||
}
|
||||
|
||||
async fn execute(&self, query: &Query) -> QueryResult<QueryHandle>;
|
||||
|
||||
async fn execute_admitted(&self, query: &Query, _admission: QueryAdmission) -> QueryResult<QueryHandle> {
|
||||
self.execute(query).await
|
||||
}
|
||||
|
||||
async fn build_query_state_machine(&self, query: Query) -> QueryResult<QueryStateMachineRef>;
|
||||
async fn build_logical_plan(&self, query_state_machine: QueryStateMachineRef) -> QueryResult<Option<Plan>>;
|
||||
async fn execute_logical_plan(
|
||||
|
||||
@@ -22,35 +22,51 @@ use rustfs_ecstore::api::error::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::object::PutObjReader as SelectPutObjReader;
|
||||
pub use rustfs_ecstore::api::object::SelectObjectSnapshot;
|
||||
pub(crate) use rustfs_ecstore::api::object::{
|
||||
PrepareSelectObjectSnapshotError, SelectObjectSnapshotReadError, SnapshotConsistencyError,
|
||||
};
|
||||
use rustfs_ecstore::api::runtime::object_store_handle as resolve_select_object_store_handle_from_backend;
|
||||
pub(crate) use rustfs_ecstore::api::set_disk::DEFAULT_READ_BUFFER_SIZE as SELECT_DEFAULT_READ_BUFFER_SIZE;
|
||||
pub(crate) use rustfs_ecstore::api::storage::ECStore as SelectStore;
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
|
||||
#[cfg(test)]
|
||||
static SELECT_TEST_OBJECT_STORE: std::sync::OnceLock<Arc<SelectStore>> = std::sync::OnceLock::new();
|
||||
|
||||
pub(crate) mod object_store {
|
||||
pub(crate) use super::storage_contracts::{HTTPRangeSpec, ObjectIO, ObjectOperations};
|
||||
pub(crate) use super::storage_contracts::HTTPRangeSpec;
|
||||
#[cfg(test)]
|
||||
pub(crate) use super::storage_contracts::ObjectIO;
|
||||
}
|
||||
|
||||
pub(crate) mod crate_boundary {
|
||||
pub(crate) use super::{
|
||||
SELECT_DEFAULT_READ_BUFFER_SIZE, SelectGetObjectReader, SelectObjectInfo, SelectObjectOptions, SelectStorageError,
|
||||
SelectStore, resolve_select_object_store_handle, select_is_err_bucket_not_found, select_is_err_object_not_found,
|
||||
PrepareSelectObjectSnapshotError, SELECT_DEFAULT_READ_BUFFER_SIZE, SelectGetObjectReader, SelectObjectOptions,
|
||||
SelectObjectSnapshotReadError, SelectStorageError, SelectStore, SnapshotConsistencyError,
|
||||
resolve_select_object_store_handle, select_is_err_bucket_not_found, select_is_err_object_not_found,
|
||||
select_is_err_version_not_found,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) type SelectGetObjectReader = <SelectStore as storage_contracts::ObjectIO>::GetObjectReader;
|
||||
pub(crate) type SelectObjectInfo = <SelectStore as storage_contracts::ObjectOperations>::ObjectInfo;
|
||||
pub(crate) type SelectObjectOptions = <SelectStore as storage_contracts::ObjectOperations>::ObjectOptions;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn select_test_ecstore_env() -> &'static rustfs_test_utils::TestECStoreEnv {
|
||||
static ENV: tokio::sync::OnceCell<rustfs_test_utils::TestECStoreEnv> = tokio::sync::OnceCell::const_new();
|
||||
ENV.get_or_init(|| async { rustfs_test_utils::TestECStoreEnv::builder().build().await })
|
||||
.await
|
||||
let env = ENV
|
||||
.get_or_init(|| async { rustfs_test_utils::TestECStoreEnv::builder().build().await })
|
||||
.await;
|
||||
let _ = SELECT_TEST_OBJECT_STORE.set(Arc::clone(&env.ecstore));
|
||||
env
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_select_object_store_handle() -> Option<Arc<SelectStore>> {
|
||||
#[cfg(test)]
|
||||
if let Some(store) = SELECT_TEST_OBJECT_STORE.get() {
|
||||
return Some(Arc::clone(store));
|
||||
}
|
||||
resolve_select_object_store_handle_from_backend()
|
||||
}
|
||||
|
||||
|
||||
@@ -54,5 +54,8 @@ s3s = { workspace = true, features = ["minio"] }
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rustfs-test-utils = { workspace = true, features = ["put-object-commit-barrier"] }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -48,8 +48,8 @@ use rustfs_s3select_api::{
|
||||
logical_planner::{LogicalPlanner, Plan},
|
||||
parser::Parser,
|
||||
session::{
|
||||
DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES, QueryExecutionOwner, QueryExecutionStatus, QueryExecutionTracker, SessionCtx,
|
||||
SessionCtxFactory,
|
||||
DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES, QueryAdmission, QueryExecutionOwner, QueryExecutionStatus,
|
||||
QueryExecutionTracker, SessionCtx, SessionCtxFactory,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -120,13 +120,20 @@ impl Drop for QueryPhaseGuard<'_> {
|
||||
#[async_trait]
|
||||
impl QueryDispatcher for SimpleQueryDispatcher {
|
||||
async fn execute_query(&self, query: &Query) -> QueryResult<Output> {
|
||||
let query_state_machine = self.build_query_state_machine(query.clone()).await?;
|
||||
let logical_plan = self.build_logical_plan(Arc::clone(&query_state_machine)).await?;
|
||||
let Some(logical_plan) = logical_plan else {
|
||||
return Ok(Output::Nil(()));
|
||||
};
|
||||
self.execute_query_inner(query, None).await
|
||||
}
|
||||
|
||||
self.execute_logical_plan(logical_plan, query_state_machine).await
|
||||
fn try_reserve_query(&self) -> QueryResult<QueryAdmission> {
|
||||
let permit = self
|
||||
.query_admission
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| QueryError::from(S3SelectPolicyError::QueryConcurrencyLimit))?;
|
||||
Ok(QueryAdmission::new(Arc::new(permit)))
|
||||
}
|
||||
|
||||
async fn execute_query_admitted(&self, query: &Query, admission: QueryAdmission) -> QueryResult<Output> {
|
||||
self.execute_query_inner(query, Some(admission)).await
|
||||
}
|
||||
|
||||
async fn build_logical_plan(&self, query_state_machine: Arc<QueryStateMachine>) -> QueryResult<Option<Plan>> {
|
||||
@@ -205,20 +212,64 @@ impl QueryDispatcher for SimpleQueryDispatcher {
|
||||
}
|
||||
|
||||
async fn build_query_state_machine(&self, query: Query) -> QueryResult<Arc<QueryStateMachine>> {
|
||||
let permit = self
|
||||
.query_admission
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| QueryError::from(S3SelectPolicyError::QueryConcurrencyLimit))?;
|
||||
self.build_query_state_machine_inner(query, None).await
|
||||
}
|
||||
}
|
||||
|
||||
impl SimpleQueryDispatcher {
|
||||
async fn execute_query_inner(&self, query: &Query, admission: Option<QueryAdmission>) -> QueryResult<Output> {
|
||||
let query_state_machine = self.build_query_state_machine_inner(query.clone(), admission).await?;
|
||||
let logical_plan = self.build_logical_plan(Arc::clone(&query_state_machine)).await?;
|
||||
let Some(logical_plan) = logical_plan else {
|
||||
return Ok(Output::Nil(()));
|
||||
};
|
||||
|
||||
self.execute_logical_plan(logical_plan, query_state_machine).await
|
||||
}
|
||||
|
||||
async fn build_query_state_machine_inner(
|
||||
&self,
|
||||
query: Query,
|
||||
admission: Option<QueryAdmission>,
|
||||
) -> QueryResult<Arc<QueryStateMachine>> {
|
||||
let query_guard = match admission {
|
||||
Some(admission) => {
|
||||
let query_guard = admission.into_query_guard().ok_or(QueryError::Cancel)?;
|
||||
if !Arc::ptr_eq(query_guard.semaphore(), &self.query_admission) {
|
||||
return Err(QueryError::Cancel);
|
||||
}
|
||||
query_guard
|
||||
}
|
||||
None => {
|
||||
let permit = self
|
||||
.query_admission
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| QueryError::from(S3SelectPolicyError::QueryConcurrencyLimit))?;
|
||||
Arc::new(permit)
|
||||
}
|
||||
};
|
||||
let query_tracker = QueryExecutionTracker::new(
|
||||
&self.query_execution_owner,
|
||||
Arc::new(permit),
|
||||
query_guard,
|
||||
Instant::now() + self.query_timeout,
|
||||
self.query_timeout.as_secs(),
|
||||
);
|
||||
let phase_guard = QueryPhaseGuard::new(&query_tracker, &self.query_execution_owner);
|
||||
let session = self
|
||||
.run_with_query_deadline(
|
||||
let session = if let Some(snapshot) = query.snapshot().cloned() {
|
||||
self.run_with_query_deadline(
|
||||
&query_tracker,
|
||||
self.session_factory
|
||||
.create_session_ctx_with_snapshot_and_tracker_and_memory_limit(
|
||||
query.context(),
|
||||
snapshot,
|
||||
query_tracker.clone(),
|
||||
self.memory_limit_bytes,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
self.run_with_query_deadline(
|
||||
&query_tracker,
|
||||
self.session_factory.create_session_ctx_with_tracker_and_memory_limit(
|
||||
query.context(),
|
||||
@@ -226,7 +277,8 @@ impl QueryDispatcher for SimpleQueryDispatcher {
|
||||
self.memory_limit_bytes,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
};
|
||||
if !query_tracker.mark_admitted(&self.query_execution_owner) {
|
||||
drop(session);
|
||||
return Err(self.query_tracker_error(&query_tracker));
|
||||
@@ -234,9 +286,6 @@ impl QueryDispatcher for SimpleQueryDispatcher {
|
||||
phase_guard.disarm();
|
||||
Ok(Arc::new(QueryStateMachine::begin_tracked(query, session, query_tracker)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl SimpleQueryDispatcher {
|
||||
async fn run_with_query_deadline<T>(
|
||||
&self,
|
||||
query_tracker: &QueryExecutionTracker,
|
||||
@@ -730,15 +779,17 @@ mod tests {
|
||||
use async_trait::async_trait;
|
||||
use datafusion::{
|
||||
arrow::{
|
||||
datatypes::{Schema, SchemaRef},
|
||||
array::{Int32Array, StringArray},
|
||||
datatypes::{DataType, Field, Schema, SchemaRef},
|
||||
record_batch::RecordBatch,
|
||||
},
|
||||
common::DataFusionError,
|
||||
execution::object_store::ObjectStoreUrl,
|
||||
object_store::{ObjectStoreExt, path::Path},
|
||||
parquet::arrow::ArrowWriter,
|
||||
physical_plan::{RecordBatchStream, stream::RecordBatchStreamAdapter},
|
||||
};
|
||||
use futures::{StreamExt, stream};
|
||||
use futures::{StreamExt, TryStreamExt, stream};
|
||||
use rustfs_s3select_api::{
|
||||
QueryError, QueryResult, S3SelectPolicyError,
|
||||
query::{
|
||||
@@ -749,14 +800,15 @@ mod tests {
|
||||
},
|
||||
logical_planner::Plan,
|
||||
session::{
|
||||
DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES, QueryExecutionOwner, QueryExecutionStatus, QueryExecutionTracker,
|
||||
SessionCtxFactory,
|
||||
DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES, QueryAdmission, QueryExecutionOwner, QueryExecutionStatus,
|
||||
QueryExecutionTracker, SessionCtxFactory,
|
||||
},
|
||||
},
|
||||
};
|
||||
use rustfs_test_utils::{PutObjectCommitBarrier, TestECStoreEnv};
|
||||
use s3s::dto::{
|
||||
CSVInput, CSVOutput, ExpressionType, FileHeaderInfo, InputSerialization, OutputSerialization, SelectObjectContentInput,
|
||||
SelectObjectContentRequest,
|
||||
CSVInput, CSVOutput, ExpressionType, FileHeaderInfo, InputSerialization, JSONInput, JSONOutput, JSONType,
|
||||
OutputSerialization, ParquetInput, SelectObjectContentInput, SelectObjectContentRequest,
|
||||
};
|
||||
use std::{
|
||||
pin::Pin,
|
||||
@@ -998,6 +1050,260 @@ mod tests {
|
||||
(dispatcher, input)
|
||||
}
|
||||
|
||||
async fn snapshot_test_env() -> &'static TestECStoreEnv {
|
||||
static ENV: tokio::sync::OnceCell<TestECStoreEnv> = tokio::sync::OnceCell::const_new();
|
||||
ENV.get_or_init(|| async { TestECStoreEnv::builder().prefix("s3select_query_snapshot").build().await })
|
||||
.await
|
||||
}
|
||||
|
||||
fn production_dispatcher(input: Arc<SelectObjectContentInput>) -> Arc<SimpleQueryDispatcher> {
|
||||
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
|
||||
let scheduler = Arc::new(LocalScheduler {});
|
||||
SimpleQueryDispatcherBuilder::default()
|
||||
.with_input(input)
|
||||
.with_default_table_provider(Arc::new(BaseTableProvider::default()))
|
||||
.with_session_factory(Arc::new(SessionCtxFactory::new(false)))
|
||||
.with_parser(Arc::new(DefaultParser::default()))
|
||||
.with_query_execution_factory(Arc::new(SqlQueryExecutionFactory::new(optimizer, scheduler)))
|
||||
.with_func_manager(Arc::new(SimpleFunctionMetadataManager::default()))
|
||||
.build()
|
||||
.expect("production query dispatcher should build")
|
||||
}
|
||||
|
||||
async fn collect_utf8_output(output: Output) -> Vec<String> {
|
||||
let Output::StreamData(stream) = output else {
|
||||
panic!("snapshot query should return rows");
|
||||
};
|
||||
stream
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("collect snapshot query output")
|
||||
.iter()
|
||||
.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()
|
||||
}
|
||||
|
||||
async fn run_snapshot_generation_race(
|
||||
input: Arc<SelectObjectContentInput>,
|
||||
old_generation: Vec<u8>,
|
||||
new_generation: Vec<u8>,
|
||||
expected_old_markers: &[&str],
|
||||
) {
|
||||
let env = snapshot_test_env().await;
|
||||
env.make_bucket(&input.bucket, false).await;
|
||||
env.put_object_bytes(&input.bucket, &input.key, old_generation).await;
|
||||
|
||||
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
|
||||
let dispatcher = production_dispatcher(Arc::clone(&input));
|
||||
let query = Query::new_with_snapshot(
|
||||
QueryContext {
|
||||
input: Arc::clone(&input),
|
||||
},
|
||||
input.request.expression.clone(),
|
||||
snapshot,
|
||||
);
|
||||
let query_state_machine = dispatcher
|
||||
.build_query_state_machine(query.clone())
|
||||
.await
|
||||
.expect("build production snapshot session");
|
||||
let logical_plan = dispatcher
|
||||
.build_logical_plan(Arc::clone(&query_state_machine))
|
||||
.await
|
||||
.expect("infer old-generation schema")
|
||||
.expect("SELECT should produce a logical plan");
|
||||
|
||||
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");
|
||||
|
||||
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> {
|
||||
Arc::new(SelectObjectContentInput {
|
||||
bucket: "s3select-json-snapshot-race".to_string(),
|
||||
expected_bucket_owner: None,
|
||||
key: "input.jsonl".to_string(),
|
||||
sse_customer_algorithm: None,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
request: SelectObjectContentRequest {
|
||||
expression: "SELECT old_marker FROM S3Object".to_string(),
|
||||
expression_type: ExpressionType::from_static(ExpressionType::SQL),
|
||||
input_serialization: InputSerialization {
|
||||
json: Some(JSONInput {
|
||||
type_: Some(JSONType::from_static(JSONType::LINES)),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
output_serialization: OutputSerialization {
|
||||
json: Some(JSONOutput::default()),
|
||||
..Default::default()
|
||||
},
|
||||
request_progress: None,
|
||||
scan_range: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn parquet_snapshot_input() -> Arc<SelectObjectContentInput> {
|
||||
Arc::new(SelectObjectContentInput {
|
||||
bucket: "s3select-parquet-snapshot-race".to_string(),
|
||||
expected_bucket_owner: None,
|
||||
key: "input.parquet".to_string(),
|
||||
sse_customer_algorithm: None,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
request: SelectObjectContentRequest {
|
||||
expression: "SELECT old_marker FROM S3Object".to_string(),
|
||||
expression_type: ExpressionType::from_static(ExpressionType::SQL),
|
||||
input_serialization: InputSerialization {
|
||||
parquet: Some(ParquetInput {}),
|
||||
..Default::default()
|
||||
},
|
||||
output_serialization: OutputSerialization {
|
||||
json: Some(JSONOutput::default()),
|
||||
..Default::default()
|
||||
},
|
||||
request_progress: None,
|
||||
scan_range: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn old_parquet_generation() -> Vec<u8> {
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("old_marker", DataType::Utf8, false)]));
|
||||
let first = RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(StringArray::from(vec!["parquet-old-footer"]))])
|
||||
.expect("build first old-generation parquet row group");
|
||||
let second = RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(StringArray::from(vec!["parquet-old-row-group"]))])
|
||||
.expect("build second old-generation parquet row group");
|
||||
let mut bytes = Vec::new();
|
||||
let mut writer = ArrowWriter::try_new(&mut bytes, schema, None).expect("create old-generation parquet writer");
|
||||
writer.write(&first).expect("write first old-generation parquet row group");
|
||||
writer.flush().expect("flush first old-generation parquet row group");
|
||||
writer.write(&second).expect("write second old-generation parquet row group");
|
||||
writer.close().expect("close old-generation parquet writer");
|
||||
bytes
|
||||
}
|
||||
|
||||
fn new_parquet_generation() -> Vec<u8> {
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("new_schema_poison", DataType::Int32, false)]));
|
||||
let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![1629]))])
|
||||
.expect("build new-generation parquet poison row group");
|
||||
let mut bytes = Vec::new();
|
||||
let mut writer = ArrowWriter::try_new(&mut bytes, schema, None).expect("create new-generation parquet writer");
|
||||
writer.write(&batch).expect("write new-generation parquet poison row group");
|
||||
writer.close().expect("close new-generation parquet writer");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn json_schema_inference_and_scan_use_one_snapshot_generation() {
|
||||
run_snapshot_generation_race(
|
||||
json_snapshot_input(),
|
||||
b"{\"old_marker\":\"json-old-schema\"}\n{\"old_marker\":\"json-old-scan\"}\n".to_vec(),
|
||||
b"{\"new_schema_poison\":1629}\n".to_vec(),
|
||||
&["json-old-schema", "json-old-scan"],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn parquet_footer_and_row_group_reads_use_one_snapshot_generation() {
|
||||
run_snapshot_generation_race(
|
||||
parquet_snapshot_input(),
|
||||
old_parquet_generation(),
|
||||
new_parquet_generation(),
|
||||
&["parquet-old-footer", "parquet-old-row-group"],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
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();
|
||||
input_mut.request.expression = "SELECT missing_binding FROM S3Object".to_string();
|
||||
|
||||
let env = snapshot_test_env().await;
|
||||
env.make_bucket(&input.bucket, false).await;
|
||||
env.put_object_bytes(&input.bucket, &input.key, b"{\"old_marker\":\"old\"}\n".to_vec())
|
||||
.await;
|
||||
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
|
||||
let dispatcher = production_dispatcher(Arc::clone(&input));
|
||||
let query = Query::new_with_snapshot(
|
||||
QueryContext {
|
||||
input: Arc::clone(&input),
|
||||
},
|
||||
input.request.expression.clone(),
|
||||
snapshot,
|
||||
);
|
||||
let query_state_machine = dispatcher
|
||||
.build_query_state_machine(query.clone())
|
||||
.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"
|
||||
);
|
||||
|
||||
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(
|
||||
permit: tokio::sync::OwnedSemaphorePermit,
|
||||
deadline: Instant,
|
||||
@@ -1118,6 +1424,42 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reserved_admission_is_handed_to_tracker_without_reacquiring() {
|
||||
let admission = Arc::new(Semaphore::new(1));
|
||||
let (dispatcher, input) = test_dispatcher(Arc::clone(&admission), Duration::from_secs(300));
|
||||
let reservation = dispatcher.try_reserve_query().expect("query reservation should succeed");
|
||||
assert_eq!(admission.available_permits(), 0);
|
||||
|
||||
let query = Query::new(QueryContext { input }, "SELECT * FROM S3Object".to_string());
|
||||
let query_state_machine = dispatcher
|
||||
.build_query_state_machine_inner(query, Some(reservation))
|
||||
.await
|
||||
.expect("reserved query should not acquire a second permit");
|
||||
|
||||
assert_eq!(admission.available_permits(), 0);
|
||||
drop(query_state_machine);
|
||||
assert_eq!(admission.available_permits(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reserved_admission_rejects_a_foreign_semaphore() {
|
||||
let admission = Arc::new(Semaphore::new(1));
|
||||
let foreign_admission = Arc::new(Semaphore::new(1));
|
||||
let foreign_permit = Arc::clone(&foreign_admission)
|
||||
.try_acquire_owned()
|
||||
.expect("foreign permit should be available");
|
||||
let reservation = QueryAdmission::new(Arc::new(foreign_permit));
|
||||
let (dispatcher, input) = test_dispatcher(Arc::clone(&admission), Duration::from_secs(300));
|
||||
let query = Query::new(QueryContext { input }, "SELECT * FROM S3Object".to_string());
|
||||
|
||||
let result = dispatcher.build_query_state_machine_inner(query, Some(reservation)).await;
|
||||
|
||||
assert!(matches!(result, Err(QueryError::Cancel)));
|
||||
assert_eq!(admission.available_permits(), 1);
|
||||
assert_eq!(foreign_admission.available_permits(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn staged_query_rejects_when_admission_is_saturated() {
|
||||
let admission = Arc::new(Semaphore::new(1));
|
||||
|
||||
@@ -26,7 +26,7 @@ use rustfs_s3select_api::{
|
||||
dispatcher::QueryDispatcher,
|
||||
execution::QueryStateMachineRef,
|
||||
logical_planner::Plan,
|
||||
session::{DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES as DEFAULT_MEMORY_LIMIT_BYTES, SessionCtxFactory},
|
||||
session::{DEFAULT_S3SELECT_MEMORY_LIMIT_BYTES as DEFAULT_MEMORY_LIMIT_BYTES, QueryAdmission, SessionCtxFactory},
|
||||
},
|
||||
server::dbms::{DatabaseManagerSystem, QueryHandle},
|
||||
};
|
||||
@@ -64,12 +64,22 @@ impl<D> DatabaseManagerSystem for RustFSms<D>
|
||||
where
|
||||
D: QueryDispatcher,
|
||||
{
|
||||
fn try_reserve_query(&self) -> QueryResult<QueryAdmission> {
|
||||
self.query_dispatcher.try_reserve_query()
|
||||
}
|
||||
|
||||
async fn execute(&self, query: &Query) -> QueryResult<QueryHandle> {
|
||||
let result = self.query_dispatcher.execute_query(query).await?;
|
||||
|
||||
Ok(QueryHandle::new(query.clone(), result))
|
||||
}
|
||||
|
||||
async fn execute_admitted(&self, query: &Query, admission: QueryAdmission) -> QueryResult<QueryHandle> {
|
||||
let result = self.query_dispatcher.execute_query_admitted(query, admission).await?;
|
||||
|
||||
Ok(QueryHandle::new(query.clone(), result))
|
||||
}
|
||||
|
||||
async fn build_query_state_machine(&self, query: Query) -> QueryResult<QueryStateMachineRef> {
|
||||
let query_state_machine = self.query_dispatcher.build_query_state_machine(query).await?;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ documentation = "https://docs.rs/rustfs-test-utils/latest/rustfs_test_utils/"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
put-object-commit-barrier = ["rustfs-ecstore/test-util"]
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
@@ -51,7 +52,7 @@ hotpath-cpu = [
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
rustfs-ecstore = { workspace = true }
|
||||
rustfs-ecstore.workspace = true
|
||||
rustfs-storage-api = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
tokio-util = { workspace = true, features = ["io", "compat"] }
|
||||
|
||||
@@ -26,8 +26,11 @@ pub(crate) mod fixture {
|
||||
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::init_bucket_metadata_sys;
|
||||
pub(crate) use rustfs_ecstore::api::disk::endpoint::Endpoint;
|
||||
pub(crate) use rustfs_ecstore::api::layout::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
pub(crate) use rustfs_ecstore::api::object::{PutObjReader, SelectObjectSnapshot};
|
||||
#[cfg(feature = "put-object-commit-barrier")]
|
||||
pub(crate) use rustfs_ecstore::api::set_disk as ecstore_set_disk;
|
||||
pub(crate) use rustfs_ecstore::api::storage::{ECStore, init_local_disks};
|
||||
pub(crate) use rustfs_storage_api::{BucketOperations, BucketOptions, MakeBucketOptions};
|
||||
pub(crate) use rustfs_storage_api::{BucketOperations, BucketOptions, MakeBucketOptions, ObjectIO};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::config::com::{delete_config, read_config, save_config};
|
||||
|
||||
@@ -30,14 +30,38 @@ mod ecstore_test_compat;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Once};
|
||||
|
||||
#[cfg(feature = "put-object-commit-barrier")]
|
||||
use ecstore_test_compat::fixture::ecstore_set_disk;
|
||||
use ecstore_test_compat::fixture::{
|
||||
BucketOperations as _, BucketOptions, ECStore, Endpoint, EndpointServerPools, Endpoints, MakeBucketOptions, PoolEndpoints,
|
||||
init_bucket_metadata_sys, init_local_disks,
|
||||
BucketOperations as _, BucketOptions, ECStore, Endpoint, EndpointServerPools, Endpoints, MakeBucketOptions, ObjectIO as _,
|
||||
PoolEndpoints, PutObjReader, SelectObjectSnapshot, init_bucket_metadata_sys, init_local_disks,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
static INIT_TRACING: Once = Once::new();
|
||||
|
||||
#[cfg(feature = "put-object-commit-barrier")]
|
||||
pub struct PutObjectCommitBarrier(ecstore_set_disk::test_util::PutObjectCommitBarrier);
|
||||
|
||||
#[cfg(feature = "put-object-commit-barrier")]
|
||||
impl PutObjectCommitBarrier {
|
||||
pub fn before_namespace(bucket: &str, object: &str) -> Self {
|
||||
Self(ecstore_set_disk::test_util::PutObjectCommitBarrier::install(
|
||||
bucket,
|
||||
object,
|
||||
ecstore_set_disk::test_util::PutObjectCommitPause::BeforeNamespace,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn wait_until_paused(&self) {
|
||||
self.0.wait_until_paused().await;
|
||||
}
|
||||
|
||||
pub async fn release_and_wait_until_namespace_pending(&self) {
|
||||
self.0.release_and_wait_until_namespace_pending().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the standard test tracing subscriber once per process
|
||||
/// (`RUST_LOG`-driven). Safe to call from every test; later calls are no-ops.
|
||||
pub fn init_tracing() {
|
||||
@@ -90,6 +114,29 @@ impl TestECStoreEnv {
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to create test bucket {bucket}: {e:?}"));
|
||||
}
|
||||
|
||||
/// Write one complete object body through the real ECStore test backend.
|
||||
pub async fn put_object_bytes(&self, bucket: &str, object: &str, bytes: Vec<u8>) {
|
||||
let mut reader = PutObjReader::from_vec(bytes);
|
||||
self.ecstore
|
||||
.put_object(bucket, object, &mut reader, &Default::default())
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to write test object {bucket}/{object}: {e:?}"));
|
||||
}
|
||||
|
||||
/// Prepare the lock-backed object snapshot used by SelectObjectContent tests.
|
||||
///
|
||||
/// The concrete ECStore snapshot type stays behind this crate's test
|
||||
/// compatibility boundary; consumers can pass the inferred value directly
|
||||
/// to the S3 Select API without importing ECStore facade paths.
|
||||
pub async fn prepare_select_object_snapshot(&self, bucket: &str, object: &str) -> Arc<SelectObjectSnapshot> {
|
||||
Arc::new(
|
||||
self.ecstore
|
||||
.prepare_select_object_snapshot(bucket, object, &Default::default(), &Default::default())
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to prepare test object snapshot {bucket}/{object}: {e:?}")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`TestECStoreEnv`]. Defaults reproduce the historical heal
|
||||
|
||||
Reference in New Issue
Block a user