mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-12 15:48:35 +00:00
feat(table-catalog): add durable catalog backups (#8266)
* feat(table-catalog): add durable catalog backups * fix(table-catalog): preserve migration lock ordering * fix(table-catalog): preserve backup recovery fences * fix(table-catalog): order backup publication locks * fix(table-catalog): preserve recovery publication fences * test(table-catalog): use the storage facade for recovery fencing * test(ecstore): repair catalog publication fixtures * fix(table-catalog): address catalog backup clippy failures * test(table-catalog): adapt generation races to publication fencing --------- Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com> Co-authored-by: overtrue <anzhengchao@gmail.com>
This commit is contained in:
@@ -4748,7 +4748,7 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
||||
bucket_incarnation_id: Uuid,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> Result<ObjectInfo, std::io::Error> {
|
||||
let publication_guard = lifecycle_expiry_publication_guard(&api, oi, bucket_incarnation_id)
|
||||
let publication_guard = lifecycle_expiry_publication_guard(&api, &oi.bucket, &oi.name, bucket_incarnation_id)
|
||||
.await
|
||||
.ok_or_else(|| std::io::Error::other("lifecycle expiry is not allowed for this bucket"))?;
|
||||
let snapshot = lifecycle_delete_config_snapshot(&api, oi)
|
||||
@@ -4838,10 +4838,14 @@ pub fn gen_transition_objname(bucket: &str) -> Result<String, Error> {
|
||||
pub async fn transition_object(api: Arc<ECStore>, oi: &ObjectInfo, lae: LcAuditEvent) -> Result<(), Error> {
|
||||
let time_ilm = Metrics::time_ilm(lae.event.action);
|
||||
|
||||
let (publication_guard, bucket_incarnation_id) = lifecycle_transition_publication_guard(&api, oi)
|
||||
.await
|
||||
.ok_or_else(|| Error::other("lifecycle transition is not allowed for this bucket"))?;
|
||||
|
||||
let etag = if let Some(etag) = &oi.etag { etag } else { "" };
|
||||
let etag = etag.to_string();
|
||||
|
||||
let opts = ObjectOptions {
|
||||
let mut opts = ObjectOptions {
|
||||
transition: TransitionOptions {
|
||||
status: lifecycle::TRANSITION_PENDING.to_string(),
|
||||
tier: lae.event.storage_class,
|
||||
@@ -4853,8 +4857,10 @@ pub async fn transition_object(api: Arc<ECStore>, oi: &ObjectInfo, lae: LcAuditE
|
||||
versioned: BucketVersioningSys::prefix_enabled(&oi.bucket, &oi.name).await,
|
||||
version_suspended: BucketVersioningSys::prefix_suspended(&oi.bucket, &oi.name).await,
|
||||
mod_time: oi.mod_time,
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
opts.add_namespace_lock_guard(&publication_guard);
|
||||
let result = api.transition_object(&oi.bucket, &oi.name, &opts).await;
|
||||
time_ilm(1)();
|
||||
result
|
||||
@@ -5458,20 +5464,26 @@ pub async fn apply_transition_rule(event: &lifecycle::Event, src: &LcEventSrc, o
|
||||
.await
|
||||
}
|
||||
|
||||
async fn lifecycle_expiry_publication_guard(
|
||||
async fn acquire_lifecycle_publication_guard(api: &ECStore, bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard, Error> {
|
||||
let lock = api
|
||||
.new_ns_lock(bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await?;
|
||||
let guard = lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(Error::other)?;
|
||||
if guard.is_lock_lost() {
|
||||
return Err(Error::other("table-bucket publication lock was lost before lifecycle admission"));
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
pub(crate) async fn lifecycle_expiry_publication_guard(
|
||||
api: &ECStore,
|
||||
oi: &ObjectInfo,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> Option<rustfs_lock::NamespaceLockGuard> {
|
||||
let result = async {
|
||||
let lock = api
|
||||
.new_ns_lock(&oi.bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await?;
|
||||
let guard = lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(Error::other)?;
|
||||
if guard.is_lock_lost() {
|
||||
return Err(Error::other("table-bucket publication lock was lost before lifecycle delete admission"));
|
||||
}
|
||||
if !metadata_boundary::lifecycle_expiry_allowed(api, &oi.bucket, bucket_incarnation_id).await? {
|
||||
let result: std::result::Result<Option<rustfs_lock::NamespaceLockGuard>, Error> = async {
|
||||
let guard = acquire_lifecycle_publication_guard(api, bucket).await?;
|
||||
if !metadata_boundary::lifecycle_expiry_allowed(api, bucket, bucket_incarnation_id).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(guard))
|
||||
@@ -5484,8 +5496,8 @@ async fn lifecycle_expiry_publication_guard(
|
||||
event = EVENT_LIFECYCLE_DELETE_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
bucket,
|
||||
object,
|
||||
operation = "authorize_lifecycle_expiry",
|
||||
error = %err,
|
||||
"Lifecycle delete admission failed"
|
||||
@@ -5495,6 +5507,34 @@ async fn lifecycle_expiry_publication_guard(
|
||||
}
|
||||
}
|
||||
|
||||
async fn lifecycle_transition_publication_guard(
|
||||
api: &ECStore,
|
||||
oi: &ObjectInfo,
|
||||
) -> Option<(rustfs_lock::NamespaceLockGuard, Uuid)> {
|
||||
let result: std::result::Result<(rustfs_lock::NamespaceLockGuard, Uuid), Error> = async {
|
||||
let guard = acquire_lifecycle_publication_guard(api, &oi.bucket).await?;
|
||||
let configs = metadata_boundary::get_expiry_configs(api, &oi.bucket).await?;
|
||||
Ok((guard, configs.bucket_incarnation_id))
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(state) => Some(state),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_TIER_OPERATION_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
operation = "authorize_lifecycle_transition",
|
||||
error = %err,
|
||||
"Lifecycle transition admission failed"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_expiry_on_transitioned_object(
|
||||
api: Arc<ECStore>,
|
||||
oi: &ObjectInfo,
|
||||
@@ -5551,7 +5591,8 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
bucket_incarnation_id: Uuid,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> bool {
|
||||
let Some(publication_guard) = lifecycle_expiry_publication_guard(&api, oi, bucket_incarnation_id).await else {
|
||||
let Some(publication_guard) = lifecycle_expiry_publication_guard(&api, &oi.bucket, &oi.name, bucket_incarnation_id).await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let snapshot = match lifecycle_delete_config_snapshot(&api, oi).await {
|
||||
@@ -5844,6 +5885,7 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::expiry_worker_count;
|
||||
use super::lifecycle_transition_publication_guard;
|
||||
use super::{
|
||||
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EVALUATION_FAILED, EVENT_LIFECYCLE_EXPIRED_DETECTED,
|
||||
@@ -13028,6 +13070,171 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn queued_noncurrent_batch_expiry_does_not_delete_from_table_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("table-noncurrent-lifecycle-{}", Uuid::new_v4().simple());
|
||||
let object = "tables/table-id/data/part-00001.parquet";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
metadata_sys::update_in(
|
||||
&ecstore.ctx,
|
||||
&bucket,
|
||||
BUCKET_VERSIONING_CONFIG,
|
||||
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
|
||||
)
|
||||
.await
|
||||
.expect("bucket versioning should be enabled");
|
||||
|
||||
let mut old_reader = PutObjReader::from_vec(b"old table data".to_vec());
|
||||
let old = ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut old_reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the historical table version should be created");
|
||||
let old_version_id = old.version_id.expect("the historical version should have an identity");
|
||||
let mut current_reader = PutObjReader::from_vec(b"current table data".to_vec());
|
||||
ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut current_reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the current table version should be created");
|
||||
|
||||
let incarnation = ecstore
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("bucket incarnation should be available");
|
||||
let publication_lock = ecstore
|
||||
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await
|
||||
.expect("table-bucket publication lock should be created");
|
||||
let enable_guard = publication_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("table-bucket enablement should acquire the publication lock");
|
||||
|
||||
let delete_store = ecstore.clone();
|
||||
let delete_bucket = bucket.clone();
|
||||
let delete_object = object.to_string();
|
||||
let mut delete = tokio::spawn(async move {
|
||||
let target = ObjectToDelete {
|
||||
object_name: delete_object,
|
||||
version_id: Some(old_version_id),
|
||||
..Default::default()
|
||||
};
|
||||
crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
|
||||
&delete_store,
|
||||
&delete_bucket,
|
||||
std::slice::from_ref(&target),
|
||||
lifecycle::Event::default(),
|
||||
incarnation,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert!(
|
||||
tokio::time::timeout(StdDuration::from_millis(100), &mut delete)
|
||||
.await
|
||||
.is_err(),
|
||||
"noncurrent lifecycle cleanup must wait for the table-bucket publication fence"
|
||||
);
|
||||
|
||||
let sys = metadata_sys::bucket_metadata_sys_of(&ecstore.ctx).expect("metadata system should be initialized");
|
||||
let sys = sys.read().await.clone();
|
||||
let mut metadata = (*sys.get(&bucket).await.expect("bucket metadata should exist")).clone();
|
||||
metadata.table_bucket_config_json = br#"{"enabled":true}"#.to_vec();
|
||||
sys.persist_and_set(metadata)
|
||||
.await
|
||||
.expect("table bucket marker should be persisted");
|
||||
sys.reload_from_store(&bucket)
|
||||
.await
|
||||
.expect("table bucket marker should become authoritative");
|
||||
drop(enable_guard);
|
||||
|
||||
let failed = tokio::time::timeout(StdDuration::from_secs(2), delete)
|
||||
.await
|
||||
.expect("noncurrent lifecycle cleanup should resume after the fence is released")
|
||||
.expect("noncurrent lifecycle cleanup task should join");
|
||||
assert_eq!(failed, 1, "table-bucket noncurrent cleanup must be rejected");
|
||||
assert!(
|
||||
ecstore
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
version_id: Some(old_version_id.to_string()),
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_ok(),
|
||||
"table data must remain after queued noncurrent cleanup is rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_transition_admission_holds_publication_reader() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("lifecycle-transition-{}", Uuid::new_v4().simple());
|
||||
let object = "tables/table-id/data/part-00001.parquet";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
let object_info = ObjectInfo {
|
||||
bucket: bucket.clone(),
|
||||
name: object.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let publication_lock = ecstore
|
||||
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await
|
||||
.expect("table-bucket publication lock should be created");
|
||||
let enable_guard = publication_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("publication writer should be acquired");
|
||||
let admission_store = ecstore.clone();
|
||||
let admission_object = object_info.clone();
|
||||
let mut admission =
|
||||
tokio::spawn(async move { lifecycle_transition_publication_guard(&admission_store, &admission_object).await });
|
||||
assert!(
|
||||
tokio::time::timeout(StdDuration::from_millis(100), &mut admission)
|
||||
.await
|
||||
.is_err(),
|
||||
"transition admission must wait for an in-progress table publication"
|
||||
);
|
||||
drop(enable_guard);
|
||||
|
||||
let (transition_guard, incarnation) = tokio::time::timeout(StdDuration::from_secs(2), admission)
|
||||
.await
|
||||
.expect("transition admission should resume after publication")
|
||||
.expect("transition admission task should join")
|
||||
.expect("transition admission should succeed");
|
||||
assert_ne!(incarnation, Uuid::nil(), "transition admission must bind a bucket incarnation");
|
||||
let blocked_writer = publication_lock.get_write_lock(StdDuration::from_millis(100)).await;
|
||||
assert!(blocked_writer.is_err(), "transition admission must retain the publication read lock");
|
||||
drop(transition_guard);
|
||||
publication_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("publication writer should proceed after transition admission releases its guard");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn queued_delete_all_rechecks_a_same_id_rule_moved_into_the_future() {
|
||||
|
||||
@@ -36,6 +36,10 @@ pub async fn delete_object_versions(
|
||||
_lc_event: lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> usize {
|
||||
if to_del.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if to_del.iter().any(|target| {
|
||||
target.version_id.is_none()
|
||||
|| (target.version_id.is_some_and(|version_id| version_id.is_nil()) && target.expected_identity.is_none())
|
||||
@@ -52,6 +56,25 @@ pub async fn delete_object_versions(
|
||||
return to_del.len();
|
||||
}
|
||||
|
||||
let Some(publication_guard) = super::bucket_lifecycle_ops::lifecycle_expiry_publication_guard(
|
||||
api,
|
||||
bucket,
|
||||
to_del[0].object_name.as_str(),
|
||||
bucket_incarnation_id,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket,
|
||||
target_count = to_del.len(),
|
||||
reason = "publication_admission_rejected",
|
||||
"Skipped lifecycle noncurrent version cleanup"
|
||||
);
|
||||
return to_del.len();
|
||||
};
|
||||
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
|
||||
Ok(snapshot) => Arc::new(snapshot),
|
||||
Err(err) => {
|
||||
@@ -78,17 +101,13 @@ pub async fn delete_object_versions(
|
||||
remaining = &[];
|
||||
}
|
||||
|
||||
let (mut deleted_objs, errors) = api
|
||||
.delete_objects(
|
||||
bucket,
|
||||
to_del.to_vec(),
|
||||
ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let mut delete_options = ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
delete_options.add_namespace_lock_guard(&publication_guard);
|
||||
let (mut deleted_objs, errors) = api.delete_objects(bucket, to_del.to_vec(), delete_options).await;
|
||||
failed += errors.iter().filter(|err| err.is_some()).count();
|
||||
|
||||
for (i, deleted_obj) in deleted_objs.iter_mut().enumerate() {
|
||||
|
||||
@@ -1237,7 +1237,8 @@ impl ObjectOptions {
|
||||
self.version_id.is_some() || !self.versioned || self.version_suspended
|
||||
}
|
||||
|
||||
pub(crate) fn add_namespace_lock_lost_signal(&mut self, signal: Arc<rustfs_lock::distributed_lock::LockLostSignal>) {
|
||||
#[doc(hidden)]
|
||||
pub fn add_namespace_lock_lost_signal(&mut self, signal: Arc<rustfs_lock::distributed_lock::LockLostSignal>) {
|
||||
#[cfg(test)]
|
||||
let test_fence = NAMESPACE_LOCK_SIGNAL_TEST_FENCES
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
|
||||
@@ -58,6 +58,12 @@ impl WriteCommitGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the loss signal of the namespace owner retained by acquisition.
|
||||
#[doc(hidden)]
|
||||
pub fn lock_lost_signal(&self) -> Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>> {
|
||||
self.guards.first().and_then(|guard| guard.lock_lost_signal())
|
||||
}
|
||||
|
||||
pub fn is_lock_lost(&self) -> bool {
|
||||
self.guards.iter().any(|guard| guard.is_lock_lost() || guard.is_released())
|
||||
}
|
||||
|
||||
@@ -121,9 +121,7 @@ async fn table_catalog_metadata_exists(ctx: &crate::runtime::instance::InstanceC
|
||||
}
|
||||
|
||||
async fn validate_table_bucket_delete_guard(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<()> {
|
||||
let table_bucket_enabled = metadata_sys::get_in(ctx, bucket)
|
||||
.await
|
||||
.is_ok_and(|metadata| metadata.table_bucket_enabled());
|
||||
let table_bucket_enabled = table_bucket_enabled_in(ctx, bucket).await?;
|
||||
if table_bucket_enabled {
|
||||
validate_table_bucket_delete_allowed(bucket, true, table_catalog_metadata_exists(ctx, bucket).await?)?;
|
||||
}
|
||||
@@ -131,6 +129,21 @@ async fn validate_table_bucket_delete_guard(ctx: &crate::runtime::instance::Inst
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn table_bucket_enabled_in(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<bool> {
|
||||
match metadata_sys::get_in(ctx, bucket).await {
|
||||
Ok(metadata) => Ok(metadata.table_bucket_enabled()),
|
||||
// A live bucket may be absent from the metadata cache during lazy
|
||||
// startup. Re-read persisted metadata before deciding that it is an
|
||||
// ordinary bucket; an actual metadata backend failure must never turn
|
||||
// a protected-bucket check into a best-effort operation.
|
||||
Err(Error::ConfigNotFound) => {
|
||||
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(ctx, bucket).await?;
|
||||
Ok(persisted && metadata.table_bucket_enabled())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket_delete_metadata_cleanup_prefixes(bucket: &str) -> [String; 2] {
|
||||
[
|
||||
table_bucket_catalog_metadata_prefix(bucket),
|
||||
@@ -298,6 +311,10 @@ impl ECStore {
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
pub async fn table_bucket_enabled(&self, bucket: &str) -> Result<bool> {
|
||||
table_bucket_enabled_in(&self.ctx, bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_bucket_policy(&self, bucket: &str) -> Result<(BucketPolicy, OffsetDateTime)> {
|
||||
let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?;
|
||||
sys.read().await.get_bucket_policy(bucket).await
|
||||
@@ -436,6 +453,26 @@ impl ECStore {
|
||||
})
|
||||
}
|
||||
|
||||
async fn acquire_bucket_publication_write_lock(&self, bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||
let lock = self
|
||||
.new_ns_lock(bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await?;
|
||||
lock.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "table_bucket_publication_write",
|
||||
bucket: bucket.to_string(),
|
||||
object: rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
}
|
||||
}
|
||||
other => StorageError::Lock(other),
|
||||
})
|
||||
}
|
||||
|
||||
async fn mark_bucket_deleted(&self, bucket: &str) -> Result<()> {
|
||||
let marker_volume = bucket_deleted_marker_volume(bucket);
|
||||
|
||||
@@ -1056,7 +1093,7 @@ impl ECStore {
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &DeleteBucketOptions,
|
||||
mut diagnostic_budget: BucketDeleteDiagnosticBudget,
|
||||
diagnostic_budget: BucketDeleteDiagnosticBudget,
|
||||
) -> Result<()> {
|
||||
if is_meta_bucketname(bucket) {
|
||||
return Err(StorageError::BucketNameInvalid(bucket.to_string()));
|
||||
@@ -1066,6 +1103,33 @@ impl ECStore {
|
||||
return Err(StorageError::BucketNameInvalid(err.to_string()));
|
||||
}
|
||||
|
||||
// Bucket deletion is a publication mutation too: a durable catalog
|
||||
// backup must not enumerate or verify table objects while their
|
||||
// containing bucket is being physically removed. Acquire this fence
|
||||
// before the lifecycle and namespace locks to match object writers.
|
||||
// `no_lock` is reserved for callers that already own the enclosing
|
||||
// mutation locks (for example, failed bucket-creation rollback).
|
||||
let publication_guard = if !opts.no_lock {
|
||||
Some(self.acquire_bucket_publication_write_lock(bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
await_bucket_namespace_operation(
|
||||
publication_guard.as_ref(),
|
||||
bucket,
|
||||
"table-bucket publication fence during bucket deletion",
|
||||
self.handle_delete_bucket_under_publication_lock(bucket, opts, diagnostic_budget),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_delete_bucket_under_publication_lock(
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &DeleteBucketOptions,
|
||||
mut diagnostic_budget: BucketDeleteDiagnosticBudget,
|
||||
) -> Result<()> {
|
||||
let bucket_lifecycle_guard = if !opts.no_lock {
|
||||
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
|
||||
} else {
|
||||
@@ -1227,9 +1291,9 @@ mod tests {
|
||||
BUCKET_DELETE_DIAGNOSTIC_MAX_ELAPSED, BUCKET_DELETE_DIAGNOSTIC_MAX_ENTRIES, BUCKET_DELETE_XLMETA_DIAGNOSTIC_MAX_BYTES,
|
||||
BucketDeleteBlockerKind, BucketDeleteDiagnosticBudget, BucketMetadataLessResidue, SCANNER_BUCKET_LIST_SET_CONCURRENCY,
|
||||
await_bucket_namespace_operation, bucket_delete_metadata_cleanup_prefixes, bucket_deleted_marker_prefix,
|
||||
bucket_deleted_marker_volume, bucket_list_set_concurrency, record_bucket_delete_blocker, run_bucket_usage_cleanup,
|
||||
run_physical_bucket_deletion, scan_metadata_less_residue, scan_metadata_less_residue_with_budget,
|
||||
should_override_created_from_metadata, validate_table_bucket_delete_allowed,
|
||||
bucket_deleted_marker_volume, bucket_list_set_concurrency, get_lock_acquire_timeout, record_bucket_delete_blocker,
|
||||
run_bucket_usage_cleanup, run_physical_bucket_deletion, scan_metadata_less_residue,
|
||||
scan_metadata_less_residue_with_budget, should_override_created_from_metadata, validate_table_bucket_delete_allowed,
|
||||
};
|
||||
use crate::bucket::metadata::{BucketMetadata, table_bucket_catalog_metadata_prefix};
|
||||
use crate::bucket::metadata_sys;
|
||||
@@ -3296,6 +3360,40 @@ mod tests {
|
||||
assert!(any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn bucket_delete_waits_for_table_publication_readers() {
|
||||
let (_disk_paths, ecstore) = setup_bucket_delete_test_env().await;
|
||||
let bucket = format!("bucket-delete-publication-{}", Uuid::new_v4().simple());
|
||||
ecstore
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
|
||||
let publication_lock = ecstore
|
||||
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await
|
||||
.expect("publication lock should be created");
|
||||
let publication_reader = publication_lock
|
||||
.get_read_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("publication reader should be acquired");
|
||||
|
||||
let delete_store = Arc::clone(&ecstore);
|
||||
let mut delete = tokio::spawn(async move { delete_store.delete_bucket(&bucket, &DeleteBucketOptions::default()).await });
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(250), &mut delete).await.is_err(),
|
||||
"bucket deletion must remain behind the table publication reader"
|
||||
);
|
||||
|
||||
drop(publication_reader);
|
||||
tokio::time::timeout(Duration::from_secs(10), &mut delete)
|
||||
.await
|
||||
.expect("bucket deletion should proceed after publication reader release")
|
||||
.expect("bucket deletion task should join")
|
||||
.expect("empty bucket deletion should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn bucket_delete_mark_delete_rejects_non_empty_bucket_without_force() {
|
||||
|
||||
@@ -68,7 +68,8 @@ RustFS S3 Tables is an Iceberg REST Catalog and table-bucket implementation on t
|
||||
| Snapshot refs | Supported | List, create/replace, delete via commits; `main` is protected; refs with explicit retention need forced delete. |
|
||||
| Iceberg views | Supported | Create, list, load, replace, exists, drop with view-scoped authorization; only view format version 1. |
|
||||
| LoadTable and table credentials endpoint | Supported | Vending only on negotiated `vended-credentials`; one temporary session scoped to the warehouse prefix and current metadata location; missing credential permission falls back to metadata-only with an explicit reason; responses carry `Cache-Control: no-store, private`. |
|
||||
| Catalog diagnostics and export | Supported | Object-backed and durable-strong stores expose recovery state, consistency, backing manifest, WAL state, migration target, single-active-writer policy, and scale validation matrix. Durable-strong responses also identify the snapshot path, ETag, and format version; diagnostics re-check the snapshot observation before returning and follow the persisted metadata directory. Until strong maintenance configuration is persisted, no count-based retention is added; reachable metadata remains protected and orphan candidates do not authorize deletion. |
|
||||
| Catalog diagnostics and export | Supported | Object-backed and durable-strong stores expose recovery state, consistency, backing manifest, WAL state, migration target, single-active-writer policy, and scale validation matrix. Durable-strong responses also identify the snapshot path, ETag, and format version; diagnostics re-check the snapshot observation before returning and use zero-file retention until strong maintenance configuration is persisted. |
|
||||
| Durable catalog backup and restore | Preview / controlled | Durable-strong only. `POST /iceberg/v1/{warehouse}/catalog/backup` seals a table-bucket snapshot behind the publication and migration fences, records catalog/object watermarks, and stores an immutable backup artifact. `POST /iceberg/v1/{warehouse}/catalog/restore` verifies those watermarks, requires an explicit replacement decision for an existing target, applies the catalog snapshot with ETag-CAS, and leaves a recoverable restore intent if maintenance-state finalization fails. It does not copy data files or claim automatic cross-region failover. |
|
||||
| Catalog import and rollback | Supported | Import/register and online rollback go through validation and commit paths. Online rollback accepts only forward-safe targets; restoring an older target that lowers watermarks is an offline disaster-recovery operation with all writers stopped. |
|
||||
| External catalog bridge | Supported operator path | Operator-supplied metadata pointer sync/import. Vendor SDK polling and policy mirroring not claimed. |
|
||||
| Multi-table transactions | Not claimed | Single-table commit atomicity only. |
|
||||
@@ -118,7 +119,7 @@ RustFS S3 Tables is an Iceberg REST Catalog and table-bucket implementation on t
|
||||
| Durable backing migration preflight | Supported | `GET /iceberg/v1/{warehouse}/catalog/migration` (and the alias) reports inventory, recovery blockers, prefix-index readiness, identifier collisions, fence state, target agreement, and per-bucket cutover readiness. |
|
||||
| Durable backing migration execution | Preview / controlled | `POST /iceberg/v1/{warehouse}/catalog/migration` fences registry changes, acquires a persistent per-bucket write fence, drains in-flight mutations, materializes the snapshot, and reports `ready_to_enable_durable_strong`. `DELETE` cancels only while the target has not advanced. Both mutations require `admin:MigrateTableCatalog`. Procedure: [s3-tables-cutover-runbook.md](../operations/s3-tables-cutover-runbook.md). |
|
||||
| Strong snapshot rolling compatibility | Supported | Reads snapshot versions 1 and 2; writes version 1 by default and version 2 only after both `RUSTFS_TABLE_CATALOG_STRONG_SNAPSHOT_V2` and `RUSTFS_TABLE_CATALOG_STRONG_SNAPSHOT_V2_FLEET_CONFIRMED` are set. A process rejects a lower format after observing a higher one; once v2 is fleet-confirmed, data-plane resolution fails closed until the persisted snapshot is v2. |
|
||||
| Disaster recovery rehearsal | Manual/live harness | `failure_coverage.py --print-disaster-recovery-rehearsal`. |
|
||||
| Disaster recovery rehearsal | Manual/live harness | `failure_coverage.py --print-disaster-recovery-rehearsal`; the controlled catalog backup/restore path additionally requires referenced table objects to remain available and does not provide cross-region object replication. |
|
||||
| Scale and fault rehearsal | Manual/live harness | `failure_coverage.py --print-scale-fault-rehearsal`. |
|
||||
| Durable strong snapshot backing cutover | Preview / controlled | `RUSTFS_TABLE_CATALOG_BACKING=durable-strong` only after every table bucket reports `SNAPSHOT_MATERIALIZED` and `ready_to_enable_durable_strong: true`. No separate external KV/WAL service is claimed; the legacy `STRONG_KV_WAL` and `CUT_OVER_LINEARIZABLE_READS` labels in v1 manifests are wire-compatibility labels, not claims. |
|
||||
| Single active writer region | Supported policy | Diagnostics publish single-active-writer semantics and read-only replica limits. |
|
||||
|
||||
@@ -42,6 +42,32 @@ DELETE /iceberg/v1/{warehouse}/catalog/migration
|
||||
|
||||
After the durable-strong state advances, cancellation fails closed; recovery requires an operator-selected restore or reverse migration.
|
||||
|
||||
## Controlled Catalog Backup And Restore
|
||||
|
||||
The durable catalog backup and restore endpoints are a controlled, same-table-bucket recovery mechanism. They are available only with `RUSTFS_TABLE_CATALOG_BACKING=durable-strong`; they protect the catalog snapshot and maintenance state, but do not copy or replicate table data, metadata, manifest, or delete objects.
|
||||
|
||||
Backup artifacts and restore intents use a strict internal format version. A binary rejects artifacts or pending intents from another format version; create a new backup after upgrading instead of assuming cross-version restore compatibility.
|
||||
|
||||
1. Stop or drain catalog writers and maintenance workers for the table bucket. The backup operation acquires the table-bucket publication fence and the durable migration fence, rejects pending commit recovery and pending restore recovery, rejects active maintenance leases, and records the current snapshot ETag, format version, catalog fingerprint, and referenced object watermarks.
|
||||
2. Create a backup with a principal authorized for `MigrateTableCatalogAction`. Backup creation persists an immutable recovery artifact and is therefore not covered by read-only catalog permission. The optional `expected-snapshot-etag` provides an additional compare-and-capture guard.
|
||||
|
||||
```text
|
||||
POST /iceberg/v1/{warehouse}/catalog/backup
|
||||
{"expected-snapshot-etag":"<current-etag>"}
|
||||
```
|
||||
|
||||
3. Keep every referenced table object available for the retention period. A successful backup is immutable and idempotent by content-derived backup ID; it is not a copy of those objects and cannot restore an object that was deleted or changed after capture.
|
||||
4. Before restoring, stop writers and maintenance workers again. Verify the target table bucket is still table-enabled, obtain its current snapshot ETag, and use `expected-snapshot-etag` whenever replacing an existing catalog. `allow-replace` must be explicitly `true` to replace a non-identical existing bucket snapshot.
|
||||
|
||||
```text
|
||||
POST /iceberg/v1/{warehouse}/catalog/restore
|
||||
{"backup-id":"<backup-id>","expected-snapshot-etag":"<current-etag>","allow-replace":true}
|
||||
```
|
||||
|
||||
5. Treat a restore conflict as a stale-target or changed-object condition, not as permission to retry blindly. The service verifies all recorded object watermarks before applying an ETag-CAS snapshot replacement. If the process fails after catalog replacement, retrying the same backup ID is safe only while maintenance state is exactly the persisted source state or already exactly the backup target state. A partial multi-object reconciliation fails closed with a conflict and requires operator inspection before manual cleanup.
|
||||
|
||||
This procedure is not cross-region failover. Cross-region recovery requires an independent object replication/backup system and an operator-selected catalog import or restore procedure.
|
||||
|
||||
## Strong Snapshot Version 1 to Version 2
|
||||
|
||||
1. Keep snapshot writes on version 1 during a rolling binary upgrade. Current binaries read both versions.
|
||||
|
||||
@@ -137,6 +137,55 @@ impl Operation for CancelTableCatalogMigrationHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CreateTableCatalogBackupHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for CreateTableCatalogBackupHandler {
|
||||
async fn call(&self, mut req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let warehouse = warehouse_from_params(¶ms)?;
|
||||
let resource = TableCatalogResource::warehouse(&warehouse);
|
||||
authorize_table_catalog_resource_request(&req, &resource, AdminAction::MigrateTableCatalogAction).await?;
|
||||
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
|
||||
let request = read_json_body_or_default::<CatalogBackupRequest>(std::mem::take(&mut req.input)).await?;
|
||||
let store = table_catalog_store_from_extensions(&req.extensions)?;
|
||||
let started = Instant::now();
|
||||
let result = store
|
||||
.create_durable_catalog_backup(&warehouse, request.expected_snapshot_etag.as_deref())
|
||||
.await
|
||||
.map_err(catalog_store_error);
|
||||
record_table_catalog_admin_operation_result("catalog-backup", &warehouse, "", "", started, &result);
|
||||
let response = result?;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RestoreTableCatalogBackupHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for RestoreTableCatalogBackupHandler {
|
||||
async fn call(&self, mut req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let warehouse = warehouse_from_params(¶ms)?;
|
||||
let resource = TableCatalogResource::warehouse(&warehouse);
|
||||
authorize_table_catalog_resource_request(&req, &resource, AdminAction::MigrateTableCatalogAction).await?;
|
||||
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
|
||||
let request = read_json_body::<CatalogRestoreRequest>(std::mem::take(&mut req.input)).await?;
|
||||
let store = table_catalog_store_from_extensions(&req.extensions)?;
|
||||
let started = Instant::now();
|
||||
let result = store
|
||||
.restore_durable_catalog_backup(
|
||||
&warehouse,
|
||||
&request.backup_id,
|
||||
request.expected_snapshot_etag.as_deref(),
|
||||
request.allow_replace,
|
||||
)
|
||||
.await
|
||||
.map_err(catalog_store_error);
|
||||
record_table_catalog_admin_operation_result("catalog-restore", &warehouse, "", "", started, &result);
|
||||
let response = result?;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BackfillTableWarehouseIndexHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -177,7 +177,8 @@ const TABLE_CATALOG_ENDPOINTS: &[&str] = &[
|
||||
"POST /v1/{prefix}/namespaces/{namespace}/views/{view}",
|
||||
"DELETE /v1/{prefix}/namespaces/{namespace}/views/{view}",
|
||||
];
|
||||
const TABLE_CATALOG_DURABLE_STRONG_ENDPOINTS: &[&str] = &[];
|
||||
const TABLE_CATALOG_DURABLE_STRONG_ENDPOINTS: &[&str] =
|
||||
&["POST /v1/{prefix}/catalog/backup", "POST /v1/{prefix}/catalog/restore"];
|
||||
|
||||
static GET_CONFIG_HANDLER: GetCatalogConfigHandler = GetCatalogConfigHandler {};
|
||||
static ENABLE_TABLE_BUCKET_HANDLER: EnableTableBucketHandler = EnableTableBucketHandler {};
|
||||
@@ -187,6 +188,8 @@ static GET_TABLE_CATALOG_MIGRATION_HANDLER: GetTableCatalogMigrationHandler = Ge
|
||||
static MATERIALIZE_TABLE_CATALOG_MIGRATION_HANDLER: MaterializeTableCatalogMigrationHandler =
|
||||
MaterializeTableCatalogMigrationHandler {};
|
||||
static CANCEL_TABLE_CATALOG_MIGRATION_HANDLER: CancelTableCatalogMigrationHandler = CancelTableCatalogMigrationHandler {};
|
||||
static CREATE_TABLE_CATALOG_BACKUP_HANDLER: CreateTableCatalogBackupHandler = CreateTableCatalogBackupHandler {};
|
||||
static RESTORE_TABLE_CATALOG_BACKUP_HANDLER: RestoreTableCatalogBackupHandler = RestoreTableCatalogBackupHandler {};
|
||||
static BACKFILL_TABLE_WAREHOUSE_INDEX_HANDLER: BackfillTableWarehouseIndexHandler = BackfillTableWarehouseIndexHandler {};
|
||||
static LIST_NAMESPACES_HANDLER: RestListNamespacesHandler = RestListNamespacesHandler {};
|
||||
static CREATE_NAMESPACE_HANDLER: RestCreateNamespaceHandler = RestCreateNamespaceHandler {};
|
||||
@@ -463,6 +466,24 @@ struct CatalogImportRequest {
|
||||
properties: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CatalogBackupRequest {
|
||||
#[serde(default, rename = "expected-snapshot-etag")]
|
||||
expected_snapshot_etag: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CatalogRestoreRequest {
|
||||
#[serde(rename = "backup-id")]
|
||||
backup_id: String,
|
||||
#[serde(default, rename = "expected-snapshot-etag")]
|
||||
expected_snapshot_etag: Option<String>,
|
||||
#[serde(default, rename = "allow-replace")]
|
||||
allow_replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ExternalCatalogBridgeRequest {
|
||||
@@ -1289,6 +1310,7 @@ struct TableCommitPublicationState {
|
||||
phase: TableCommitPublicationPhase,
|
||||
bucket_fence: Option<String>,
|
||||
table_fence: Option<(String, String, String)>,
|
||||
catalog_migration_read_permit: bool,
|
||||
observed_objects: BTreeMap<(String, String), TableCommitObservedObject>,
|
||||
guards: Vec<crate::table_catalog::TableCatalogLockGuard>,
|
||||
}
|
||||
@@ -1490,15 +1512,27 @@ where
|
||||
}
|
||||
publication.bucket_fence = Some(table_bucket.to_string());
|
||||
}
|
||||
let publication_lock = crate::table_catalog::default_table_bucket_publication_lock_path();
|
||||
let guard = match self.backend.acquire_write_lock(table_bucket, &publication_lock).await {
|
||||
Ok(guard) => guard,
|
||||
let migration_guards = match self.backend.acquire_catalog_migration_read_guards(table_bucket).await {
|
||||
Ok(guards) => guards,
|
||||
Err(err) => {
|
||||
self.publication.lock().bucket_fence = None;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
self.publication.lock().guards.push(guard);
|
||||
let publication_lock = crate::table_catalog::default_table_bucket_publication_lock_path();
|
||||
let guard = match self.backend.acquire_write_lock(table_bucket, &publication_lock).await {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
let mut publication = self.publication.lock();
|
||||
publication.bucket_fence = None;
|
||||
publication.catalog_migration_read_permit = false;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let mut publication = self.publication.lock();
|
||||
publication.catalog_migration_read_permit = !migration_guards.is_empty();
|
||||
publication.guards.extend(migration_guards);
|
||||
publication.guards.push(guard);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1634,6 +1668,7 @@ where
|
||||
publication.observed_objects.clear();
|
||||
publication.bucket_fence = None;
|
||||
publication.table_fence = None;
|
||||
publication.catalog_migration_read_permit = false;
|
||||
publication.phase = TableCommitPublicationPhase::Complete;
|
||||
}
|
||||
|
||||
@@ -1749,6 +1784,17 @@ where
|
||||
self.backend.delete_object(bucket, object).await
|
||||
}
|
||||
|
||||
async fn delete_object_if_match(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
expected_etag: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.authorize(bucket, object, S3Action::DeleteObjectAction).await?;
|
||||
self.ensure_observation_allowed(bucket, object)?;
|
||||
self.backend.delete_object_if_match(bucket, object, expected_etag).await
|
||||
}
|
||||
|
||||
async fn delete_object_unlocked(&self, bucket: &str, object: &str) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.delete_object(bucket, object).await
|
||||
}
|
||||
@@ -1805,6 +1851,17 @@ where
|
||||
&& publication.guards.iter().all(|guard| !guard.is_lock_lost())
|
||||
}
|
||||
|
||||
fn acquires_catalog_migration_read_permit(&self) -> bool {
|
||||
self.backend.acquires_catalog_migration_read_permit()
|
||||
}
|
||||
|
||||
fn catalog_migration_read_permit_status(&self) -> Option<bool> {
|
||||
let publication = self.publication.lock();
|
||||
publication
|
||||
.catalog_migration_read_permit
|
||||
.then(|| publication.guards.iter().all(|guard| !guard.is_lock_lost()))
|
||||
}
|
||||
|
||||
fn complete_table_commit_publication(&self) {
|
||||
self.complete_publication();
|
||||
}
|
||||
@@ -2299,11 +2356,10 @@ fn table_catalog_object_store_from_extensions(extensions: &http::Extensions) ->
|
||||
async fn table_bucket_enabled_from_extensions(extensions: &http::Extensions, bucket: &str) -> S3Result<bool> {
|
||||
let store = runtime_sources::object_store_from_extensions(extensions)
|
||||
.ok_or_else(|| table_catalog_internal_error("request object store is not initialized"))?;
|
||||
let metadata = store
|
||||
.get_bucket_metadata(bucket)
|
||||
store
|
||||
.table_bucket_enabled(bucket)
|
||||
.await
|
||||
.map_err(|err| s3_error!(InvalidRequest, "failed to load table bucket metadata for {bucket}: {}", err))?;
|
||||
Ok(metadata.table_bucket_enabled())
|
||||
.map_err(|err| s3_error!(InvalidRequest, "failed to load table bucket metadata for {bucket}: {}", err))
|
||||
}
|
||||
|
||||
async fn ensure_table_bucket_enabled_from_extensions(extensions: &http::Extensions, bucket: &str) -> S3Result<()> {
|
||||
|
||||
@@ -54,6 +54,16 @@ fn register_table_catalog_prefix_routes(r: &mut S3Router<AdminOperation>, prefix
|
||||
format!("{prefix}/{{warehouse}}/catalog/migration").as_str(),
|
||||
AdminOperation(&CANCEL_TABLE_CATALOG_MIGRATION_HANDLER),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::POST,
|
||||
format!("{prefix}/{{warehouse}}/catalog/backup").as_str(),
|
||||
AdminOperation(&CREATE_TABLE_CATALOG_BACKUP_HANDLER),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::POST,
|
||||
format!("{prefix}/{{warehouse}}/catalog/restore").as_str(),
|
||||
AdminOperation(&RESTORE_TABLE_CATALOG_BACKUP_HANDLER),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::POST,
|
||||
format!("{prefix}/{{warehouse}}/catalog/warehouse-index/backfill").as_str(),
|
||||
|
||||
@@ -540,7 +540,11 @@ fn catalog_config_response_reports_durable_strong_backing_override() {
|
||||
.contains(&"POST /v1/{prefix}/namespaces/{namespace}/properties")
|
||||
);
|
||||
assert!(response.endpoints.contains(&"POST /v1/{prefix}/tables/rename"));
|
||||
assert_eq!(response.endpoints.as_slice(), TABLE_CATALOG_ENDPOINTS);
|
||||
assert!(response.endpoints.ends_with(TABLE_CATALOG_DURABLE_STRONG_ENDPOINTS));
|
||||
assert_eq!(
|
||||
response.endpoints.len(),
|
||||
TABLE_CATALOG_ENDPOINTS.len() + TABLE_CATALOG_DURABLE_STRONG_ENDPOINTS.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -687,6 +691,8 @@ fn table_catalog_handlers_require_table_admin_actions() {
|
||||
("DisableTableBucketHandler", "AdminAction::SetTableBucketAction"),
|
||||
("GetTableBucketHandler", "AdminAction::GetTableBucketAction"),
|
||||
("GetTableCatalogMigrationHandler", "AdminAction::GetTableCatalogAction"),
|
||||
("CreateTableCatalogBackupHandler", "AdminAction::MigrateTableCatalogAction"),
|
||||
("RestoreTableCatalogBackupHandler", "AdminAction::MigrateTableCatalogAction"),
|
||||
("RestListNamespacesHandler", "AdminAction::GetTableNamespaceAction"),
|
||||
("RestCreateNamespaceHandler", "AdminAction::SetTableNamespaceAction"),
|
||||
("RestGetNamespaceHandler", "AdminAction::GetTableNamespaceAction"),
|
||||
@@ -1003,6 +1009,23 @@ fn enable_table_bucket_response_fences_before_marker_and_catalog_entry() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_commit_publication_acquires_migration_permits_before_publication_lock() {
|
||||
let src = table_catalog_handler_source();
|
||||
let block = function_block(&src, "async fn begin_bucket_publication");
|
||||
let migration_permits = block
|
||||
.find("acquire_catalog_migration_read_guards(table_bucket)")
|
||||
.expect("publication should acquire catalog migration permits");
|
||||
let publication_lock = block
|
||||
.find("acquire_write_lock(table_bucket, &publication_lock)")
|
||||
.expect("publication should acquire its bucket lock");
|
||||
|
||||
assert!(
|
||||
migration_permits < publication_lock,
|
||||
"catalog migration permits must be acquired before the table-bucket publication lock"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_catalog_resource_builds_policy_object_scope() {
|
||||
let namespace = crate::table_catalog::Namespace::parse("analytics.daily_events").expect("namespace should parse");
|
||||
@@ -1066,6 +1089,8 @@ fn rest_catalog_mvp_routes_use_implemented_handlers() {
|
||||
let _: &GetTableCatalogMigrationHandler = &GET_TABLE_CATALOG_MIGRATION_HANDLER;
|
||||
let _: &MaterializeTableCatalogMigrationHandler = &MATERIALIZE_TABLE_CATALOG_MIGRATION_HANDLER;
|
||||
let _: &CancelTableCatalogMigrationHandler = &CANCEL_TABLE_CATALOG_MIGRATION_HANDLER;
|
||||
let _: &CreateTableCatalogBackupHandler = &CREATE_TABLE_CATALOG_BACKUP_HANDLER;
|
||||
let _: &RestoreTableCatalogBackupHandler = &RESTORE_TABLE_CATALOG_BACKUP_HANDLER;
|
||||
let _: &RestListNamespacesHandler = &LIST_NAMESPACES_HANDLER;
|
||||
let _: &RestCreateNamespaceHandler = &CREATE_NAMESPACE_HANDLER;
|
||||
let _: &RestGetNamespaceHandler = &GET_NAMESPACE_HANDLER;
|
||||
@@ -5735,11 +5760,15 @@ async fn commit_publication_lock_order_remains_compatible_with_old_maintenance_n
|
||||
)
|
||||
.await
|
||||
});
|
||||
metadata_backend.wait_for_lock_attempts(2).await;
|
||||
metadata_backend.wait_for_lock_attempts(3).await;
|
||||
let migration_lock = crate::table_catalog::TableCatalogObjectPaths::default().backing_migration_fence_lock_path("warehouse");
|
||||
assert_eq!(
|
||||
metadata_backend.lock_attempts.lock().await.as_slice(),
|
||||
&[
|
||||
(
|
||||
"warehouse".to_string(),
|
||||
crate::table_catalog::default_table_bucket_publication_lock_path()
|
||||
),
|
||||
(crate::admin::storage_api::RUSTFS_META_BUCKET.to_string(), migration_lock),
|
||||
(crate::admin::storage_api::RUSTFS_META_BUCKET.to_string(), table_path),
|
||||
]
|
||||
@@ -13509,6 +13538,253 @@ async fn legacy_commit_rejects_mismatched_table_uuid_before_commit() {
|
||||
assert_eq!(unchanged.generation, current.generation);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ColdStrongRegistrationBackend<B> {
|
||||
inner: B,
|
||||
armed: Arc<std::sync::atomic::AtomicBool>,
|
||||
reader_ready: Arc<tokio::sync::Notify>,
|
||||
resume: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl<B> ColdStrongRegistrationBackend<B> {
|
||||
async fn pause_after_reader(&self) {
|
||||
if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
||||
self.reader_ready.notify_one();
|
||||
self.resume.notified().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<B: crate::table_catalog::TableCatalogObjectBackend> crate::table_catalog::TableCatalogObjectBackend
|
||||
for ColdStrongRegistrationBackend<B>
|
||||
{
|
||||
fn strong_catalog_runtime(&self) -> Option<crate::table_catalog::StrongTableCatalogRuntime> {
|
||||
self.inner.strong_catalog_runtime()
|
||||
}
|
||||
|
||||
fn acquires_catalog_migration_read_permit(&self) -> bool {
|
||||
self.inner.acquires_catalog_migration_read_permit()
|
||||
}
|
||||
|
||||
async fn acquire_catalog_migration_read_guards(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Vec<crate::table_catalog::TableCatalogLockGuard>> {
|
||||
let guards = self.inner.acquire_catalog_migration_read_guards(bucket).await?;
|
||||
self.pause_after_reader().await;
|
||||
Ok(guards)
|
||||
}
|
||||
|
||||
async fn acquire_read_lock(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<crate::table_catalog::TableCatalogLockGuard> {
|
||||
let guard = self.inner.acquire_read_lock(bucket, object).await?;
|
||||
if object == crate::table_catalog::TableCatalogObjectPaths::default().backing_migration_global_fence_lock_path() {
|
||||
self.pause_after_reader().await;
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
async fn acquire_write_lock(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<crate::table_catalog::TableCatalogLockGuard> {
|
||||
self.inner.acquire_write_lock(bucket, object).await
|
||||
}
|
||||
|
||||
async fn read_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::TableCatalogObject>> {
|
||||
self.inner.read_object(bucket, object).await
|
||||
}
|
||||
|
||||
async fn read_object_unlocked(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::TableCatalogObject>> {
|
||||
self.inner.read_object_unlocked(bucket, object).await
|
||||
}
|
||||
|
||||
async fn read_object_limited(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
max_size: usize,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::TableCatalogObject>> {
|
||||
self.inner.read_object_limited(bucket, object, max_size).await
|
||||
}
|
||||
|
||||
async fn object_metadata(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::TableCatalogObjectMetadata>> {
|
||||
self.inner.object_metadata(bucket, object).await
|
||||
}
|
||||
|
||||
async fn object_exists(&self, bucket: &str, object: &str) -> crate::table_catalog::TableCatalogStoreResult<bool> {
|
||||
self.inner.object_exists(bucket, object).await
|
||||
}
|
||||
|
||||
async fn put_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: Vec<u8>,
|
||||
precondition: crate::table_catalog::TableCatalogPutPrecondition,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.inner.put_object(bucket, object, data, precondition).await
|
||||
}
|
||||
|
||||
async fn put_object_unlocked(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: Vec<u8>,
|
||||
precondition: crate::table_catalog::TableCatalogPutPrecondition,
|
||||
guards: Vec<crate::admin::storage_api::object::WriteCommitGuard>,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.inner
|
||||
.put_object_unlocked(bucket, object, data, precondition, guards)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_object(&self, bucket: &str, object: &str) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.inner.delete_object(bucket, object).await
|
||||
}
|
||||
|
||||
async fn delete_object_if_match(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
expected_etag: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.inner.delete_object_if_match(bucket, object, expected_etag).await
|
||||
}
|
||||
|
||||
async fn list_objects(&self, bucket: &str, prefix: &str) -> crate::table_catalog::TableCatalogStoreResult<Vec<String>> {
|
||||
self.inner.list_objects(bucket, prefix).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_strong_registration_completes_while_other_bucket_backup_waits() {
|
||||
use crate::table_catalog::{
|
||||
EcStoreTableCatalogObjectBackend, StrongTableCatalogRuntime, StrongTableCatalogStore, TableCatalogPutPrecondition,
|
||||
};
|
||||
let (_temp_dir, _disk_paths, object_store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
|
||||
let bucket = format!("cold-register-{}", Uuid::new_v4().simple());
|
||||
let backup_bucket = format!("other-backup-{}", Uuid::new_v4().simple());
|
||||
for bucket in [&bucket, &backup_bucket] {
|
||||
object_store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create table bucket");
|
||||
enable_table_bucket_marker(&object_store, bucket)
|
||||
.await
|
||||
.expect("enable table bucket marker");
|
||||
}
|
||||
let inner = EcStoreTableCatalogObjectBackend::new_with_strong_runtime(object_store, StrongTableCatalogRuntime::default());
|
||||
let backend = ColdStrongRegistrationBackend {
|
||||
inner: inner.clone(),
|
||||
armed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
reader_ready: Arc::new(tokio::sync::Notify::new()),
|
||||
resume: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let catalog = StrongTableCatalogStore::new(backend.clone());
|
||||
for bucket in [&bucket, &backup_bucket] {
|
||||
catalog
|
||||
.put_table_bucket(table_bucket_entry_from_metadata_marker(bucket))
|
||||
.await
|
||||
.expect("seed durable table bucket");
|
||||
}
|
||||
let namespace = crate::table_catalog::Namespace::parse("analytics").expect("namespace");
|
||||
catalog
|
||||
.create_namespace(crate::table_catalog::NamespaceEntry {
|
||||
version: crate::table_catalog::TABLE_CATALOG_ENTRY_VERSION,
|
||||
table_bucket: bucket.clone(),
|
||||
namespace: namespace.public_name(),
|
||||
namespace_id: namespace.storage_id(),
|
||||
state: crate::table_catalog::TableCatalogEntryState::Active,
|
||||
properties: BTreeMap::new(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed namespace");
|
||||
let table = crate::table_catalog::IdentifierSegment::parse("events").expect("table");
|
||||
let location = crate::table_catalog::default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
||||
inner
|
||||
.put_object(
|
||||
&bucket,
|
||||
&location,
|
||||
serde_json::to_vec(&test_table_metadata_json(
|
||||
"metadata-table-uuid",
|
||||
&format!("s3://{bucket}/tables/table-id"),
|
||||
))
|
||||
.expect("metadata JSON"),
|
||||
TableCatalogPutPrecondition::IfAbsent,
|
||||
)
|
||||
.await
|
||||
.expect("seed valid metadata");
|
||||
let publication = TableCommitObjectBackend::trusted(backend.clone());
|
||||
backend.armed.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let registration_bucket = bucket.clone();
|
||||
let mut registration = tokio::spawn(async move {
|
||||
let result = register_table_response(
|
||||
&catalog,
|
||||
&publication,
|
||||
®istration_bucket,
|
||||
&namespace,
|
||||
RegisterTableRequest {
|
||||
name: "events".to_string(),
|
||||
metadata_location: location,
|
||||
overwrite: false,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
publication.finish(result).await
|
||||
});
|
||||
tokio::time::timeout(StdDuration::from_secs(5), backend.reader_ready.notified())
|
||||
.await
|
||||
.expect("registration holds first real global reader");
|
||||
// The backup targets another bucket, so its documented writer-drain precondition is met.
|
||||
let backup_catalog = StrongTableCatalogStore::new(inner);
|
||||
let mut backup = Box::pin(backup_catalog.create_durable_catalog_backup(&backup_bucket, None));
|
||||
assert!(
|
||||
futures::poll!(backup.as_mut()).is_pending(),
|
||||
"actual backup writer must queue behind held registration reader"
|
||||
);
|
||||
backend.resume.notify_one();
|
||||
let result = tokio::time::timeout(StdDuration::from_secs(2), &mut registration).await;
|
||||
if result.is_err() {
|
||||
// Prove that the queued backup, rather than ECStore I/O latency, caused the stall.
|
||||
drop(backup);
|
||||
tokio::time::timeout(StdDuration::from_secs(5), &mut registration)
|
||||
.await
|
||||
.expect("registration must resume once only the queued backup is cancelled")
|
||||
.expect("registration control joins")
|
||||
.expect("registration control succeeds");
|
||||
panic!("cold registration stalled behind a queued backup while retaining its own global reader");
|
||||
}
|
||||
result
|
||||
.expect("registration should complete without a recursive global read")
|
||||
.expect("registration joins")
|
||||
.expect("registration succeeds");
|
||||
tokio::time::timeout(StdDuration::from_secs(5), backup)
|
||||
.await
|
||||
.expect("the other bucket backup should resume when registration releases its reader")
|
||||
.expect("the other bucket backup succeeds");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn disable_table_bucket_preserves_objects_and_can_be_reenabled() {
|
||||
|
||||
@@ -1049,6 +1049,18 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
|
||||
MIGRATE_TABLE_CATALOG,
|
||||
RouteRiskLevel::High,
|
||||
),
|
||||
admin(
|
||||
HttpMethod::Post,
|
||||
"/iceberg/v1/{warehouse}/catalog/backup",
|
||||
MIGRATE_TABLE_CATALOG,
|
||||
RouteRiskLevel::High,
|
||||
),
|
||||
admin(
|
||||
HttpMethod::Post,
|
||||
"/iceberg/v1/{warehouse}/catalog/restore",
|
||||
MIGRATE_TABLE_CATALOG,
|
||||
RouteRiskLevel::High,
|
||||
),
|
||||
admin(
|
||||
HttpMethod::Post,
|
||||
"/iceberg/v1/{warehouse}/catalog/warehouse-index/backfill",
|
||||
@@ -1345,6 +1357,18 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
|
||||
MIGRATE_TABLE_CATALOG,
|
||||
RouteRiskLevel::High,
|
||||
),
|
||||
admin(
|
||||
HttpMethod::Post,
|
||||
"/_iceberg/v1/{warehouse}/catalog/backup",
|
||||
MIGRATE_TABLE_CATALOG,
|
||||
RouteRiskLevel::High,
|
||||
),
|
||||
admin(
|
||||
HttpMethod::Post,
|
||||
"/_iceberg/v1/{warehouse}/catalog/restore",
|
||||
MIGRATE_TABLE_CATALOG,
|
||||
RouteRiskLevel::High,
|
||||
),
|
||||
admin(
|
||||
HttpMethod::Post,
|
||||
"/_iceberg/v1/{warehouse}/catalog/warehouse-index/backfill",
|
||||
@@ -1929,7 +1953,7 @@ mod tests {
|
||||
let table_specs = ADMIN_ROUTE_POLICY_SPECS
|
||||
.iter()
|
||||
.filter(|spec| spec.path().starts_with("/iceberg/v1") || spec.path().starts_with("/_iceberg/v1"));
|
||||
assert_eq!(table_specs.count(), 102);
|
||||
assert_eq!(table_specs.count(), 106);
|
||||
assert_action(HttpMethod::Put, "/iceberg/v1/buckets/{warehouse}", SET_TABLE_BUCKET);
|
||||
assert_action(HttpMethod::Delete, "/iceberg/v1/buckets/{warehouse}", SET_TABLE_BUCKET);
|
||||
assert_action(HttpMethod::Delete, "/_iceberg/v1/buckets/{warehouse}", SET_TABLE_BUCKET);
|
||||
@@ -2114,6 +2138,10 @@ mod tests {
|
||||
assert_action(HttpMethod::Post, "/_iceberg/v1/{warehouse}/catalog/migration", MIGRATE_TABLE_CATALOG);
|
||||
assert_action(HttpMethod::Delete, "/iceberg/v1/{warehouse}/catalog/migration", MIGRATE_TABLE_CATALOG);
|
||||
assert_action(HttpMethod::Delete, "/_iceberg/v1/{warehouse}/catalog/migration", MIGRATE_TABLE_CATALOG);
|
||||
assert_action(HttpMethod::Post, "/iceberg/v1/{warehouse}/catalog/backup", MIGRATE_TABLE_CATALOG);
|
||||
assert_action(HttpMethod::Post, "/_iceberg/v1/{warehouse}/catalog/backup", MIGRATE_TABLE_CATALOG);
|
||||
assert_action(HttpMethod::Post, "/iceberg/v1/{warehouse}/catalog/restore", MIGRATE_TABLE_CATALOG);
|
||||
assert_action(HttpMethod::Post, "/_iceberg/v1/{warehouse}/catalog/restore", MIGRATE_TABLE_CATALOG);
|
||||
assert_action(
|
||||
HttpMethod::Post,
|
||||
"/iceberg/v1/{warehouse}/catalog/warehouse-index/backfill",
|
||||
|
||||
@@ -462,6 +462,8 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
|
||||
table_route_sample(Method::GET, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
|
||||
table_route_sample(Method::POST, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
|
||||
table_route_sample(Method::DELETE, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
|
||||
table_route_sample(Method::POST, "/{warehouse}/catalog/backup", "/analytics/catalog/backup"),
|
||||
table_route_sample(Method::POST, "/{warehouse}/catalog/restore", "/analytics/catalog/restore"),
|
||||
table_route_sample(
|
||||
Method::POST,
|
||||
"/{warehouse}/catalog/warehouse-index/backfill",
|
||||
@@ -665,6 +667,8 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
|
||||
compat_table_route_sample(Method::GET, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
|
||||
compat_table_route_sample(Method::POST, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
|
||||
compat_table_route_sample(Method::DELETE, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
|
||||
compat_table_route_sample(Method::POST, "/{warehouse}/catalog/backup", "/analytics/catalog/backup"),
|
||||
compat_table_route_sample(Method::POST, "/{warehouse}/catalog/restore", "/analytics/catalog/restore"),
|
||||
compat_table_route_sample(
|
||||
Method::POST,
|
||||
"/{warehouse}/catalog/warehouse-index/backfill",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use super::storage_api::multipart_usecase::ECStore;
|
||||
use super::storage_api::multipart_usecase::access::{
|
||||
TableDataPlaneListAccess, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard,
|
||||
has_bypass_governance_header, replication_request_authorized,
|
||||
has_bypass_governance_header, replication_request_authorized, retained_table_data_plane_publication_guards,
|
||||
};
|
||||
use super::storage_api::multipart_usecase::bucket::quota::checker::QuotaChecker;
|
||||
use super::storage_api::multipart_usecase::bucket::{
|
||||
@@ -710,6 +710,7 @@ impl DefaultMultipartUsecase {
|
||||
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
|
||||
|
||||
let upload_user_defined = multipart_info.as_ref().map(|info| info.user_defined.clone());
|
||||
let table_data_plane_publication_guards = retained_table_data_plane_publication_guards(&req);
|
||||
|
||||
let quota_metadata_sys = self.bucket_metadata_sys();
|
||||
let quota_tracking = quota_metadata_sys.is_some();
|
||||
@@ -778,7 +779,9 @@ impl DefaultMultipartUsecase {
|
||||
let key = key.clone();
|
||||
let upload_id = upload_id.clone();
|
||||
let opts = opts.clone();
|
||||
let table_data_plane_publication_guards = table_data_plane_publication_guards;
|
||||
async move {
|
||||
let _table_data_plane_publication_guards = table_data_plane_publication_guards;
|
||||
let obj_info = store
|
||||
.clone()
|
||||
.complete_multipart_upload(&bucket, &key, &upload_id, uploaded_parts, &opts)
|
||||
|
||||
@@ -787,6 +787,7 @@ impl DefaultObjectUsecase {
|
||||
let has_bucket_metadata = self.bucket_metadata_sys().is_some();
|
||||
let cache_adapter = self.object_data_cache();
|
||||
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
|
||||
let table_data_plane_publication_guards = retained_table_data_plane_publication_guards(&req);
|
||||
|
||||
let copy_commit = spawn_traced_join({
|
||||
let store = Arc::clone(&store);
|
||||
@@ -796,10 +797,12 @@ impl DefaultObjectUsecase {
|
||||
let key = key.clone();
|
||||
let src_opts = src_opts.clone();
|
||||
let dst_opts = dst_opts.clone();
|
||||
let table_data_plane_publication_guards = table_data_plane_publication_guards;
|
||||
async move {
|
||||
let _source_bucket_lifecycle_guard = source_bucket_lifecycle_guard;
|
||||
let _destination_bucket_lifecycle_guard_storage = destination_bucket_lifecycle_guard_storage;
|
||||
let _self_copy_lock_guard = _self_copy_lock_guard;
|
||||
let _table_data_plane_publication_guards = table_data_plane_publication_guards;
|
||||
|
||||
let oi = store
|
||||
.copy_object(&src_bucket, &src_key, &bucket, &key, &mut src_info, &src_opts, &dst_opts)
|
||||
|
||||
@@ -120,7 +120,7 @@ static DELETE_SNAPSHOT_TEST_HOOK: OnceLock<Mutex<Option<DeleteSnapshotTestHook>>
|
||||
static DELETE_SOURCE_TEST_HOOK: OnceLock<Mutex<Option<DeleteSnapshotTestHook>>> = OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
static DELETE_OBJECTS_AUTH_TEST_HOOK: OnceLock<Mutex<Option<DeleteSnapshotTestHook>>> = OnceLock::new();
|
||||
static DELETE_OBJECTS_GENERATION_TEST_HOOK: OnceLock<Mutex<Option<DeleteSnapshotTestHook>>> = OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_delete_snapshot_test_hook(
|
||||
@@ -185,24 +185,24 @@ async fn wait_for_delete_source_test_hook(bucket: &str) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_delete_objects_auth_test_hook(
|
||||
pub(crate) fn install_delete_objects_generation_test_hook(
|
||||
bucket: String,
|
||||
loaded: Arc<tokio::sync::Barrier>,
|
||||
resume: Arc<tokio::sync::Barrier>,
|
||||
) {
|
||||
*DELETE_OBJECTS_AUTH_TEST_HOOK
|
||||
*DELETE_OBJECTS_GENERATION_TEST_HOOK
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.expect("delete objects auth test hook lock should not be poisoned") = Some((bucket, loaded, resume));
|
||||
.expect("delete objects generation test hook lock should not be poisoned") = Some((bucket, loaded, resume));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn wait_for_delete_objects_auth_test_hook(bucket: &str) {
|
||||
async fn wait_for_delete_objects_generation_test_hook(bucket: &str) {
|
||||
let hook = {
|
||||
let mut slot = DELETE_OBJECTS_AUTH_TEST_HOOK
|
||||
let mut slot = DELETE_OBJECTS_GENERATION_TEST_HOOK
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.expect("delete objects auth test hook lock should not be poisoned");
|
||||
.expect("delete objects generation test hook lock should not be poisoned");
|
||||
if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) {
|
||||
slot.take()
|
||||
} else {
|
||||
@@ -481,6 +481,8 @@ impl DefaultObjectUsecase {
|
||||
// Capture the bucket generation before per-object authorization, but
|
||||
// do not expose a bucket-state error unless at least one object is authorized.
|
||||
let bucket_generation = load_bucket_generation_from_store(store.as_ref(), &req, &bucket).await;
|
||||
#[cfg(test)]
|
||||
wait_for_delete_objects_generation_test_hook(&bucket).await;
|
||||
|
||||
let bypass_governance = has_bypass_governance_header(&req.headers);
|
||||
|
||||
@@ -595,8 +597,6 @@ impl DefaultObjectUsecase {
|
||||
let _ = helper.complete(&result);
|
||||
return result;
|
||||
}
|
||||
#[cfg(test)]
|
||||
wait_for_delete_objects_auth_test_hook(&bucket).await;
|
||||
req.extensions.insert(bucket_generation?);
|
||||
let bucket_lock_enabled = object_lock_checks_required(&bucket).await;
|
||||
|
||||
@@ -2098,7 +2098,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn execute_delete_objects_rejects_bucket_recreated_after_authorization() {
|
||||
async fn execute_delete_objects_rejects_bucket_recreated_before_publication_admission() {
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
|
||||
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
@@ -2125,7 +2125,7 @@ mod tests {
|
||||
.expect("authorized bucket metadata should be cached"))
|
||||
.clone();
|
||||
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
|
||||
metadata.policy_config_json = policy_json.into_bytes();
|
||||
metadata.policy_config_json = policy_json.clone().into_bytes();
|
||||
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
|
||||
.await
|
||||
.expect("publish test bucket policy");
|
||||
@@ -2146,7 +2146,7 @@ mod tests {
|
||||
req.extensions.insert(crate::storage::access::ReqInfo::default());
|
||||
let loaded = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let resume = Arc::new(tokio::sync::Barrier::new(2));
|
||||
install_delete_objects_auth_test_hook(bucket.clone(), Arc::clone(&loaded), Arc::clone(&resume));
|
||||
install_delete_objects_generation_test_hook(bucket.clone(), Arc::clone(&loaded), Arc::clone(&resume));
|
||||
|
||||
let usecase = DefaultObjectUsecase::with_context(Some(context));
|
||||
let delete = tokio::spawn(async move { usecase.execute_delete_objects(req).await });
|
||||
@@ -2166,6 +2166,15 @@ mod tests {
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate same bucket name");
|
||||
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
|
||||
.await
|
||||
.expect("recreated bucket metadata should be cached"))
|
||||
.clone();
|
||||
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
|
||||
metadata.policy_config_json = policy_json.into_bytes();
|
||||
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
|
||||
.await
|
||||
.expect("republish the same policy for admission");
|
||||
let mut reader = PutObjReader::from_vec(b"new generation".to_vec());
|
||||
store
|
||||
.put_object(&bucket, "object", &mut reader, &ObjectOptions::default())
|
||||
@@ -2176,7 +2185,7 @@ mod tests {
|
||||
let err = delete
|
||||
.await
|
||||
.expect("delete objects task should join")
|
||||
.expect_err("old authorization must not delete from the recreated bucket");
|
||||
.expect_err("the captured generation must not delete from the recreated bucket");
|
||||
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
|
||||
store
|
||||
.get_object_info(&bucket, "object", &ObjectOptions::default())
|
||||
|
||||
@@ -627,6 +627,7 @@ struct ExtractPreparedMember<R = ExtractStagedBody> {
|
||||
body: R,
|
||||
write_plan: WritePlan,
|
||||
opts: ObjectOptions,
|
||||
table_data_plane_publication_guards: Option<TableDataPlanePublicationGuards>,
|
||||
replication: ReplicateDecision,
|
||||
producer_identity: rustfs_scanner::SegmentInvalidationProducerIdentity,
|
||||
staging_permit: OwnedSemaphorePermit,
|
||||
@@ -765,7 +766,8 @@ where
|
||||
actual_size,
|
||||
body,
|
||||
write_plan,
|
||||
opts,
|
||||
mut opts,
|
||||
table_data_plane_publication_guards,
|
||||
replication,
|
||||
producer_identity,
|
||||
staging_permit,
|
||||
@@ -775,6 +777,9 @@ where
|
||||
let hrd = write_plan.apply(hrd, actual_size).map_err(ApiError::from)?;
|
||||
let (hrd, member_read_failed) = track_extract_member_read_errors(hrd).map_err(ApiError::from)?;
|
||||
let mut reader = PutObjReader::new(hrd);
|
||||
if let Some(guards) = table_data_plane_publication_guards.as_ref() {
|
||||
guards.add_lock_loss_fences(&mut opts);
|
||||
}
|
||||
let _ = invalidate_object_data_cache_before_mutation(&context.cache_adapter, &context.bucket, &key).await;
|
||||
|
||||
let (obj_info, backfilled_old_current_size) = match context
|
||||
@@ -2460,6 +2465,7 @@ impl DefaultObjectUsecase {
|
||||
if iam_requirements.replication {
|
||||
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::ReplicateObjectAction)).await);
|
||||
}
|
||||
let table_data_plane_publication_guards = retained_table_data_plane_publication_guards(&auth_req);
|
||||
drop(auth_req);
|
||||
if archive_entry_mod_time.is_some() {
|
||||
opts.mod_time = archive_entry_mod_time;
|
||||
@@ -2605,6 +2611,7 @@ impl DefaultObjectUsecase {
|
||||
body: std::io::Cursor::new(Vec::new()),
|
||||
write_plan,
|
||||
opts,
|
||||
table_data_plane_publication_guards,
|
||||
replication,
|
||||
producer_identity: rustfs_scanner::SegmentInvalidationProducerIdentity::DirectoryObject,
|
||||
staging_permit,
|
||||
@@ -2630,6 +2637,7 @@ impl DefaultObjectUsecase {
|
||||
body: f,
|
||||
write_plan,
|
||||
opts,
|
||||
table_data_plane_publication_guards,
|
||||
replication,
|
||||
producer_identity: rustfs_scanner::SegmentInvalidationProducerIdentity::PutObject,
|
||||
staging_permit,
|
||||
@@ -2660,6 +2668,7 @@ impl DefaultObjectUsecase {
|
||||
body,
|
||||
write_plan,
|
||||
opts,
|
||||
table_data_plane_publication_guards,
|
||||
replication,
|
||||
producer_identity: if is_dir {
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::DirectoryObject
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
use super::*;
|
||||
|
||||
use crate::app::object_data_cache::invalidate_object_data_cache_after_complete_multipart_success;
|
||||
use crate::app::runtime_sources::current_app_context;
|
||||
use crate::app::storage_api::multipart_usecase::contract::multipart::{CompletePart, MultipartOperations as _};
|
||||
use crate::app::storage_api::object_usecase::compression::is_multipart_disk_compression_enabled;
|
||||
use crate::app::storage_api::object_usecase::io::WriteEncryption;
|
||||
@@ -35,6 +36,10 @@ use crate::app::storage_api::object_usecase::sse::{
|
||||
};
|
||||
use crate::capacity::record_capacity_write;
|
||||
use crate::runtime_sources::NotifyInterface;
|
||||
use crate::table_catalog::{
|
||||
EcStoreTableCatalogObjectBackend, TableCatalogLockGuard, TableCatalogObjectBackend, TableCatalogStoreError,
|
||||
default_table_bucket_publication_lock_path,
|
||||
};
|
||||
use http::HeaderName;
|
||||
|
||||
/// Inputs of an internal object write. Content and user metadata follow the
|
||||
@@ -227,6 +232,34 @@ impl InternalPutObjectEvent {
|
||||
}
|
||||
|
||||
impl DefaultObjectUsecase {
|
||||
async fn acquire_table_bucket_publication_guard(&self, bucket: &str) -> Result<Option<TableCatalogLockGuard>, ApiError> {
|
||||
let store = self.object_store().ok_or_else(not_initialized)?;
|
||||
if !store.table_bucket_enabled(bucket).await.map_err(ApiError::from)? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let context = self
|
||||
.context
|
||||
.clone()
|
||||
.or_else(current_app_context)
|
||||
.ok_or_else(not_initialized)?;
|
||||
let backend = EcStoreTableCatalogObjectBackend::new_with_strong_runtime(
|
||||
context.object_store(),
|
||||
context.table_catalog_strong_runtime(),
|
||||
);
|
||||
let guard = backend
|
||||
.acquire_read_lock(bucket, &default_table_bucket_publication_lock_path())
|
||||
.await
|
||||
.map_err(|err: TableCatalogStoreError| {
|
||||
let code = match err {
|
||||
TableCatalogStoreError::Unavailable(_) => S3ErrorCode::ServiceUnavailable,
|
||||
_ => S3ErrorCode::InternalError,
|
||||
};
|
||||
api_error_from_s3(S3Error::with_message(code, "failed to acquire table publication guard"))
|
||||
})?;
|
||||
Ok(Some(guard))
|
||||
}
|
||||
|
||||
/// Write one object through the ordinary PutObject path on behalf of a
|
||||
/// trusted internal caller.
|
||||
///
|
||||
@@ -265,6 +298,7 @@ impl DefaultObjectUsecase {
|
||||
headers.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("*"));
|
||||
}
|
||||
validate_internal_write_target(&key, &bucket, &headers).await?;
|
||||
let table_publication_guard = self.acquire_table_bucket_publication_guard(&bucket).await?;
|
||||
remove_source_replication_bookkeeping(&mut internal_metadata);
|
||||
|
||||
let write = PutObjectWriteRequest {
|
||||
@@ -300,6 +334,7 @@ impl DefaultObjectUsecase {
|
||||
emit_events,
|
||||
preserve_delete_marker,
|
||||
expected_bucket_incarnation_id,
|
||||
table_publication_guard,
|
||||
},
|
||||
};
|
||||
let committed = self
|
||||
@@ -319,6 +354,7 @@ impl DefaultObjectUsecase {
|
||||
pub(crate) async fn internal_create_multipart_upload(&self, ctx: &InternalPutContext) -> Result<String, ApiError> {
|
||||
let headers = internal_put_headers(&ctx.content_headers)?;
|
||||
validate_internal_write_target(&ctx.key, &ctx.bucket, &headers).await?;
|
||||
let table_publication_guard = self.acquire_table_bucket_publication_guard(&ctx.bucket).await?;
|
||||
let store = self.object_store().ok_or_else(not_initialized)?;
|
||||
|
||||
let mut metadata = ctx.user_metadata.clone();
|
||||
@@ -372,6 +408,11 @@ impl DefaultObjectUsecase {
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
opts.expected_bucket_incarnation_id = ctx.expected_bucket_incarnation_id;
|
||||
if let Some(guard) = table_publication_guard.as_ref()
|
||||
&& let Some(signal) = guard.lock_lost_signal()
|
||||
{
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
|
||||
let dsc = must_replicate_object(
|
||||
&ctx.bucket,
|
||||
@@ -543,6 +584,7 @@ impl DefaultObjectUsecase {
|
||||
validate_table_catalog_object_mutation(&bucket, &key)
|
||||
.await
|
||||
.map_err(api_error_from_s3)?;
|
||||
let table_publication_guard = self.acquire_table_bucket_publication_guard(&bucket).await?;
|
||||
let store = self.object_store().ok_or_else(not_initialized)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -552,6 +594,11 @@ impl DefaultObjectUsecase {
|
||||
let mut opts =
|
||||
get_complete_multipart_upload_opts_with_replication_authorization(&headers, false).map_err(ApiError::from)?;
|
||||
opts.expected_bucket_incarnation_id = ctx.expected_bucket_incarnation_id;
|
||||
if let Some(guard) = table_publication_guard.as_ref()
|
||||
&& let Some(signal) = guard.lock_lost_signal()
|
||||
{
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
opts.preserve_etag = ctx.preserve_etag.clone();
|
||||
opts.preserve_delete_marker = ctx.preserve_delete_marker;
|
||||
let (versioned, version_suspended) = BucketVersioningSys::write_state(&bucket, &key)
|
||||
@@ -667,7 +714,9 @@ impl DefaultObjectUsecase {
|
||||
let key = key.clone();
|
||||
let upload_id = upload_id.to_string();
|
||||
let opts = opts.clone();
|
||||
let table_publication_guard = table_publication_guard;
|
||||
async move {
|
||||
let _table_publication_guard = table_publication_guard;
|
||||
let obj_info = store
|
||||
.clone()
|
||||
.complete_multipart_upload(&bucket, &key, &upload_id, parts, &opts)
|
||||
|
||||
@@ -20,10 +20,11 @@ use rustfs_io_metrics::buffered_write;
|
||||
use crate::storage_api::table::get_bucket_metadata;
|
||||
|
||||
use super::storage_api::object_usecase::access::{
|
||||
PostObjectRequestMarker, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_request,
|
||||
delete_object_authorize_action, has_bypass_governance_header, load_bucket_generation_from_store, odm_read_generation,
|
||||
prepare_odm_read_generation, recursive_force_delete_has_authenticated_caller, replication_request_authorized, req_info_mut,
|
||||
req_info_ref,
|
||||
PostObjectRequestMarker, TableDataPlanePublicationGuards, apply_bucket_generation_guard,
|
||||
apply_copy_source_bucket_generation_guard, authorize_request, delete_object_authorize_action, has_bypass_governance_header,
|
||||
load_bucket_generation_from_store, odm_read_generation, prepare_odm_read_generation,
|
||||
recursive_force_delete_has_authenticated_caller, replication_request_authorized, req_info_mut, req_info_ref,
|
||||
retained_table_data_plane_publication_guards,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use super::storage_api::object_usecase::bucket::quota::BucketQuota;
|
||||
|
||||
@@ -971,6 +971,7 @@ pub(super) enum PutObjectOrigin<'a> {
|
||||
emit_events: bool,
|
||||
preserve_delete_marker: bool,
|
||||
expected_bucket_incarnation_id: Option<Uuid>,
|
||||
table_publication_guard: Option<table_catalog::TableCatalogLockGuard>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -995,6 +996,33 @@ impl PutObjectOrigin<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn add_lock_loss_fence(&self, opts: &mut ObjectOptions) {
|
||||
if let Self::Internal {
|
||||
table_publication_guard: Some(guard),
|
||||
..
|
||||
} = self
|
||||
&& let Some(signal) = guard.lock_lost_signal()
|
||||
{
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_table_publication_guard(&mut self) -> Option<table_catalog::TableCatalogLockGuard> {
|
||||
match self {
|
||||
Self::Internal {
|
||||
table_publication_guard, ..
|
||||
} => table_publication_guard.take(),
|
||||
Self::S3 { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn table_data_plane_publication_guards(&self) -> Option<TableDataPlanePublicationGuards> {
|
||||
match self {
|
||||
Self::S3 { req, .. } => retained_table_data_plane_publication_guards(req),
|
||||
Self::Internal { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sse_principal(&self) -> Option<SseKmsPrincipal> {
|
||||
match self {
|
||||
Self::S3 { req, .. } => SseKmsPrincipal::from_request(req),
|
||||
@@ -1439,7 +1467,7 @@ impl DefaultObjectUsecase {
|
||||
object_lock,
|
||||
content_md5,
|
||||
preserve_etag,
|
||||
origin,
|
||||
mut origin,
|
||||
} = write;
|
||||
let PutObjectSseInput {
|
||||
server_side_encryption,
|
||||
@@ -1648,6 +1676,7 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from("app_put_opts_build", put_opts_stage_start);
|
||||
origin.apply_bucket_generation_guard(&bucket, &mut opts)?;
|
||||
origin.add_lock_loss_fence(&mut opts);
|
||||
apply_put_request_object_lock_opts(
|
||||
&bucket,
|
||||
&object_lock_config_state,
|
||||
@@ -1966,7 +1995,11 @@ impl DefaultObjectUsecase {
|
||||
let request_id = request_id.clone();
|
||||
let put_path = put_path.to_string();
|
||||
let put_admission = put_admission;
|
||||
let table_publication_guard = origin.take_table_publication_guard();
|
||||
let table_data_plane_publication_guards = origin.table_data_plane_publication_guards();
|
||||
async move {
|
||||
let _table_publication_guard = table_publication_guard;
|
||||
let _table_data_plane_publication_guards = table_data_plane_publication_guards;
|
||||
let _put_admission = put_admission;
|
||||
let object_traffic_progress = object_traffic_health
|
||||
.as_deref()
|
||||
|
||||
@@ -472,9 +472,11 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
|
||||
let restore_worker_guard = restore_worker_guard.take();
|
||||
let table_data_plane_publication_guards = retained_table_data_plane_publication_guards(&req);
|
||||
spawn_traced(async move {
|
||||
let _restore_worker_guard = restore_worker_guard;
|
||||
let opts = ObjectOptions {
|
||||
let _table_data_plane_publication_guards = table_data_plane_publication_guards;
|
||||
let mut opts = ObjectOptions {
|
||||
transition: TransitionOptions {
|
||||
restore_request: rreq_clone,
|
||||
restore_expiry,
|
||||
@@ -487,6 +489,9 @@ impl DefaultObjectUsecase {
|
||||
user_defined: restore_operation_metadata,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(guards) = _table_data_plane_publication_guards.as_ref() {
|
||||
guards.add_lock_loss_fences(&mut opts);
|
||||
}
|
||||
|
||||
if let Err(err) = store_clone
|
||||
.restore_transitioned_object(&bucket_clone, &object_clone, &opts)
|
||||
|
||||
@@ -272,11 +272,11 @@ pub(crate) mod access {
|
||||
pub(crate) use crate::storage::storage_api::access_consumer::ReqInfo;
|
||||
pub(crate) use crate::storage::storage_api::access_consumer::{
|
||||
PostObjectRequestMarker, TABLE_DATA_PLANE_LIST_CURSOR_PREFIX, TableDataPlaneListAccess, TableDataPlaneListCursorPosition,
|
||||
apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_request,
|
||||
bucket_config_mutation_incarnation, delete_object_authorize_action, has_bypass_governance_header,
|
||||
TableDataPlanePublicationGuards, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard,
|
||||
authorize_request, bucket_config_mutation_incarnation, delete_object_authorize_action, has_bypass_governance_header,
|
||||
load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
|
||||
prepare_list_buckets_iam_authorization, prepare_odm_read_generation, recursive_force_delete_has_authenticated_caller,
|
||||
replication_request_authorized, req_info_mut, req_info_ref,
|
||||
replication_request_authorized, req_info_mut, req_info_ref, retained_table_data_plane_publication_guards,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+114
-41
@@ -138,7 +138,7 @@ struct InternalObjectAuthorization;
|
||||
struct StagedMultipartPartAuthorization;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct TableDataPlanePublicationGuards {
|
||||
pub(crate) struct TableDataPlanePublicationGuards {
|
||||
state: Arc<parking_lot::Mutex<TableDataPlanePublicationState>>,
|
||||
}
|
||||
|
||||
@@ -146,10 +146,20 @@ struct TableDataPlanePublicationGuards {
|
||||
struct TableDataPlanePublicationState {
|
||||
keys: BTreeSet<(String, String)>,
|
||||
guards: Vec<Box<dyn Send>>,
|
||||
lock_lost_signals: Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
resources: HashMap<(String, String), crate::table_catalog::TableDataPlaneResource>,
|
||||
missing_resources: BTreeSet<(String, String)>,
|
||||
}
|
||||
|
||||
impl TableDataPlanePublicationGuards {
|
||||
pub(crate) fn add_lock_loss_fences(&self, options: &mut ObjectOptions) {
|
||||
let state = self.state.lock();
|
||||
for signal in &state.lock_lost_signals {
|
||||
options.add_namespace_lock_lost_signal(Arc::clone(signal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const TABLE_DATA_PLANE_LIST_CURSOR_PREFIX: &str = "rustfs-table-list:v1:";
|
||||
const TABLE_DATA_PLANE_LIST_CURSOR_VERSION: u8 = 1;
|
||||
const TABLE_DATA_PLANE_LIST_CURSOR_KEY_CONTEXT: &[u8] = b"rustfs-table-list-cursor-key:v1";
|
||||
@@ -400,38 +410,38 @@ struct CopySourceBucketGenerationGuard {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
type RestoreAuthorizationTestHook = (String, tokio::sync::oneshot::Sender<()>, tokio::sync::oneshot::Receiver<()>);
|
||||
type ObjectGenerationTestHook = (String, tokio::sync::oneshot::Sender<()>, tokio::sync::oneshot::Receiver<()>);
|
||||
|
||||
#[cfg(test)]
|
||||
static RESTORE_AUTHORIZATION_TEST_HOOK: OnceLock<std::sync::Mutex<Option<RestoreAuthorizationTestHook>>> = OnceLock::new();
|
||||
static OBJECT_GENERATION_TEST_HOOK: OnceLock<std::sync::Mutex<Option<ObjectGenerationTestHook>>> = OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
fn install_restore_authorization_test_hook(
|
||||
fn install_object_generation_test_hook(
|
||||
bucket: String,
|
||||
authorized: tokio::sync::oneshot::Sender<()>,
|
||||
loaded: tokio::sync::oneshot::Sender<()>,
|
||||
resume: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
*RESTORE_AUTHORIZATION_TEST_HOOK
|
||||
*OBJECT_GENERATION_TEST_HOOK
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("restore authorization test hook lock should not be poisoned") = Some((bucket, authorized, resume));
|
||||
.expect("object generation test hook lock should not be poisoned") = Some((bucket, loaded, resume));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn wait_for_restore_authorization_test_hook(bucket: &str) {
|
||||
async fn wait_for_object_generation_test_hook(bucket: &str) {
|
||||
let hook = {
|
||||
let mut slot = RESTORE_AUTHORIZATION_TEST_HOOK
|
||||
let mut slot = OBJECT_GENERATION_TEST_HOOK
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("restore authorization test hook lock should not be poisoned");
|
||||
.expect("object generation test hook lock should not be poisoned");
|
||||
if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) {
|
||||
slot.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some((_bucket, authorized, resume)) = hook {
|
||||
let _ = authorized.send(());
|
||||
if let Some((_bucket, loaded, resume)) = hook {
|
||||
let _ = loaded.send(());
|
||||
let _ = resume.await;
|
||||
}
|
||||
}
|
||||
@@ -569,15 +579,25 @@ pub(crate) fn apply_bucket_generation_guard<T>(req: &S3Request<T>, bucket: &str,
|
||||
if req.extensions.get::<std::sync::Arc<ServerContextSlot>>().is_some() {
|
||||
return Err(s3_error!(InternalError, "bucket generation guard is missing"));
|
||||
}
|
||||
if let Some(publication_guards) = req.extensions.get::<TableDataPlanePublicationGuards>() {
|
||||
publication_guards.add_lock_loss_fences(opts);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
if guard.bucket != bucket {
|
||||
return Err(s3_error!(InternalError, "bucket generation guard does not match request bucket"));
|
||||
}
|
||||
opts.expected_bucket_incarnation_id = Some(guard.incarnation_id);
|
||||
if let Some(publication_guards) = req.extensions.get::<TableDataPlanePublicationGuards>() {
|
||||
publication_guards.add_lock_loss_fences(opts);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn retained_table_data_plane_publication_guards<T>(req: &S3Request<T>) -> Option<TableDataPlanePublicationGuards> {
|
||||
req.extensions.get::<TableDataPlanePublicationGuards>().cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_copy_source_bucket_generation_guard<T>(
|
||||
req: &S3Request<T>,
|
||||
bucket: &str,
|
||||
@@ -587,6 +607,9 @@ pub(crate) fn apply_copy_source_bucket_generation_guard<T>(
|
||||
if req.extensions.get::<std::sync::Arc<ServerContextSlot>>().is_some() {
|
||||
return Err(s3_error!(InternalError, "copy source bucket generation guard is missing"));
|
||||
}
|
||||
if let Some(publication_guards) = req.extensions.get::<TableDataPlanePublicationGuards>() {
|
||||
publication_guards.add_lock_loss_fences(opts);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
if guard.bucket != bucket {
|
||||
@@ -596,6 +619,9 @@ pub(crate) fn apply_copy_source_bucket_generation_guard<T>(
|
||||
));
|
||||
}
|
||||
opts.expected_bucket_incarnation_id = Some(guard.incarnation_id);
|
||||
if let Some(publication_guards) = req.extensions.get::<TableDataPlanePublicationGuards>() {
|
||||
publication_guards.add_lock_loss_fences(opts);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1654,9 +1680,13 @@ async fn retain_table_data_plane_publication_guard<T>(
|
||||
let guard = crate::table_catalog::TableCatalogObjectBackend::acquire_read_lock(&backend, table_bucket, lock_object)
|
||||
.await
|
||||
.map_err(table_publication_guard_error)?;
|
||||
let lock_lost_signal = guard.lock_lost_signal();
|
||||
let mut state = retained.state.lock();
|
||||
state.keys.insert(key);
|
||||
state.guards.push(Box::new(guard));
|
||||
if let Some(signal) = lock_lost_signal {
|
||||
state.lock_lost_signals.push(signal);
|
||||
}
|
||||
drop(state);
|
||||
req.extensions.insert(retained);
|
||||
Ok(())
|
||||
@@ -1702,9 +1732,8 @@ async fn table_bucket_enabled_for_data_plane<T>(req: &S3Request<T>, bucket: &str
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match request_object_store(req)?.get_bucket_metadata(bucket).await {
|
||||
Ok(metadata) => Ok(metadata.table_bucket_enabled()),
|
||||
Err(StorageError::ConfigNotFound) => Ok(false),
|
||||
match request_object_store(req)?.table_bucket_enabled(bucket).await {
|
||||
Ok(table_bucket_enabled) => Ok(table_bucket_enabled),
|
||||
Err(err) if is_err_bucket_not_found(&err) => Ok(false),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
@@ -2411,6 +2440,8 @@ impl S3Access for FS {
|
||||
async fn delete_object(&self, req: &mut S3Request<DeleteObjectInput>) -> S3Result<()> {
|
||||
let bucket = req.input.bucket.clone();
|
||||
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
|
||||
#[cfg(test)]
|
||||
wait_for_object_generation_test_hook(&bucket).await;
|
||||
// Preserve DeleteObject's established NoSuchBucket response instead of
|
||||
// letting policy lookup turn a missing bucket into AccessDenied.
|
||||
if let Err(err) = &bucket_generation
|
||||
@@ -3283,14 +3314,14 @@ impl S3Access for FS {
|
||||
async fn restore_object(&self, req: &mut S3Request<RestoreObjectInput>) -> S3Result<()> {
|
||||
let bucket = req.input.bucket.clone();
|
||||
let bucket_generation = load_bucket_generation(self, req, &bucket).await;
|
||||
#[cfg(test)]
|
||||
wait_for_object_generation_test_hook(&bucket).await;
|
||||
let req_info = ext_req_info_mut(&mut req.extensions)?;
|
||||
req_info.bucket = Some(req.input.bucket.clone());
|
||||
req_info.object = Some(req.input.key.clone());
|
||||
req_info.version_id = req.input.version_id.clone();
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::RestoreObjectAction)).await?;
|
||||
#[cfg(test)]
|
||||
wait_for_restore_authorization_test_hook(&bucket).await;
|
||||
req.extensions.insert(bucket_generation?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3377,7 +3408,7 @@ mod tests {
|
||||
apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorization_conditions,
|
||||
bucket_policy_needs_existing_object_tag_from_hint, bucket_website_config_authorize_action,
|
||||
classify_bucket_policy_raw_load_error, complete_multipart_upload_authorize_action, delete_object_authorize_action,
|
||||
get_bucket_policy_authorize_action, has_write_offset_bytes_header, install_restore_authorization_test_hook,
|
||||
get_bucket_policy_authorize_action, has_write_offset_bytes_header, install_object_generation_test_hook,
|
||||
legal_hold_write_requested, list_parts_authorize_action, load_bucket_policy_existing_object_tag_hint,
|
||||
maybe_merge_object_tag_conditions, merge_list_bucket_query_conditions, merge_request_object_tag_conditions,
|
||||
owner_can_bypass_policy_deny, post_object_authorize_action, put_bucket_policy_authorize_action, request_context_from_req,
|
||||
@@ -4987,9 +5018,16 @@ mod tests {
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[test]
|
||||
#[serial]
|
||||
async fn delete_object_access_captures_authorized_bucket_incarnation() {
|
||||
fn delete_object_access_keeps_snapshot_across_recreation_before_admission() {
|
||||
crate::app::gating_test_env::run_large_stack_test(
|
||||
"delete-object-generation-guard",
|
||||
delete_object_access_keeps_snapshot_across_recreation_before_admission_inner,
|
||||
);
|
||||
}
|
||||
|
||||
async fn delete_object_access_keeps_snapshot_across_recreation_before_admission_inner() {
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let server_ctx = ServerContextSlot::new();
|
||||
let app_context = Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms)));
|
||||
@@ -5021,7 +5059,7 @@ mod tests {
|
||||
.expect("new bucket metadata should be cached"))
|
||||
.clone();
|
||||
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test bucket policy should parse"));
|
||||
metadata.policy_config_json = policy_json.into_bytes();
|
||||
metadata.policy_config_json = policy_json.clone().into_bytes();
|
||||
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
|
||||
.await
|
||||
.expect("test bucket policy should be published");
|
||||
@@ -5035,20 +5073,21 @@ mod tests {
|
||||
ensure_req_info(&mut req);
|
||||
req.extensions.insert(fs.server_ctx().clone());
|
||||
|
||||
fs.delete_object(&mut req)
|
||||
let captured_incarnation_id = store
|
||||
.bucket_incarnation_id(&bucket)
|
||||
.await
|
||||
.expect("anonymous DeleteObject should be authorized by the test policy");
|
||||
let mut opts = crate::storage::ObjectOptions::default();
|
||||
apply_bucket_generation_guard(&req, &bucket, &mut opts).expect("request snapshot should apply to DeleteObject options");
|
||||
assert_eq!(
|
||||
opts.expected_bucket_incarnation_id,
|
||||
Some(
|
||||
store
|
||||
.bucket_incarnation_id(&bucket)
|
||||
.await
|
||||
.expect("bucket incarnation should remain readable")
|
||||
)
|
||||
);
|
||||
.expect("read the bucket generation before admission");
|
||||
let (loaded_tx, loaded_rx) = tokio::sync::oneshot::channel();
|
||||
let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
|
||||
install_object_generation_test_hook(bucket.clone(), loaded_tx, resume_rx);
|
||||
let access = tokio::spawn(async move {
|
||||
let result = fs.delete_object(&mut req).await;
|
||||
(result, req)
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), loaded_rx)
|
||||
.await
|
||||
.expect("DeleteObject should reach the generation snapshot hook")
|
||||
.expect("DeleteObject access should remain paused before admission");
|
||||
|
||||
store
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
@@ -5058,12 +5097,37 @@ mod tests {
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate the same bucket name");
|
||||
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
|
||||
.await
|
||||
.expect("recreated bucket metadata should be cached"))
|
||||
.clone();
|
||||
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
|
||||
metadata.policy_config_json = policy_json.into_bytes();
|
||||
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
|
||||
.await
|
||||
.expect("republish the same policy for admission");
|
||||
let mut reader = crate::storage::PutObjReader::from_vec(b"new generation".to_vec());
|
||||
store
|
||||
.put_object(&bucket, "object", &mut reader, &crate::storage::ObjectOptions::default())
|
||||
.await
|
||||
.expect("put the new-generation object");
|
||||
|
||||
assert_ne!(
|
||||
captured_incarnation_id,
|
||||
store
|
||||
.bucket_incarnation_id(&bucket)
|
||||
.await
|
||||
.expect("read the recreated bucket generation")
|
||||
);
|
||||
resume_tx
|
||||
.send(())
|
||||
.expect("DeleteObject should still be paused before admission");
|
||||
let (result, req) = access.await.expect("DeleteObject access task should join");
|
||||
result.expect("the equivalent policy should authorize DeleteObject");
|
||||
let mut opts = crate::storage::ObjectOptions::default();
|
||||
apply_bucket_generation_guard(&req, &bucket, &mut opts).expect("request snapshot should apply to DeleteObject options");
|
||||
assert_eq!(opts.expected_bucket_incarnation_id, Some(captured_incarnation_id));
|
||||
|
||||
let err = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(app_context))
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
@@ -5206,14 +5270,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn restore_object_access_keeps_authorized_bucket_incarnation_across_recreation() {
|
||||
fn restore_object_access_keeps_snapshot_across_recreation_before_admission() {
|
||||
crate::app::gating_test_env::run_large_stack_test(
|
||||
"restore-object-generation-guard",
|
||||
restore_object_access_keeps_authorized_bucket_incarnation_across_recreation_inner,
|
||||
restore_object_access_keeps_snapshot_across_recreation_before_admission_inner,
|
||||
);
|
||||
}
|
||||
|
||||
async fn restore_object_access_keeps_authorized_bucket_incarnation_across_recreation_inner() {
|
||||
async fn restore_object_access_keeps_snapshot_across_recreation_before_admission_inner() {
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let server_ctx = ServerContextSlot::new();
|
||||
let app_context = Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms)));
|
||||
@@ -5237,7 +5301,7 @@ mod tests {
|
||||
.expect("authorized bucket metadata should be cached"))
|
||||
.clone();
|
||||
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
|
||||
metadata.policy_config_json = policy_json.into_bytes();
|
||||
metadata.policy_config_json = policy_json.clone().into_bytes();
|
||||
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
|
||||
.await
|
||||
.expect("publish the RestoreObject policy");
|
||||
@@ -5261,7 +5325,7 @@ mod tests {
|
||||
req.extensions.insert(fs.server_ctx().clone());
|
||||
let (authorized_tx, authorized_rx) = tokio::sync::oneshot::channel();
|
||||
let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
|
||||
install_restore_authorization_test_hook(bucket.clone(), authorized_tx, resume_rx);
|
||||
install_object_generation_test_hook(bucket.clone(), authorized_tx, resume_rx);
|
||||
|
||||
let access = tokio::spawn(async move {
|
||||
let result = fs.restore_object(&mut req).await;
|
||||
@@ -5269,7 +5333,7 @@ mod tests {
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), authorized_rx)
|
||||
.await
|
||||
.expect("RestoreObject authorization should reach the test hook")
|
||||
.expect("RestoreObject should reach the generation snapshot hook")
|
||||
.expect("RestoreObject access must not fail before reaching the test hook");
|
||||
|
||||
store
|
||||
@@ -5280,6 +5344,15 @@ mod tests {
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate the bucket with the same name");
|
||||
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket)
|
||||
.await
|
||||
.expect("recreated bucket metadata should be cached"))
|
||||
.clone();
|
||||
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse"));
|
||||
metadata.policy_config_json = policy_json.into_bytes();
|
||||
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
|
||||
.await
|
||||
.expect("republish the same policy for admission");
|
||||
let recreated_incarnation_id = store
|
||||
.bucket_incarnation_id(&bucket)
|
||||
.await
|
||||
@@ -5290,7 +5363,7 @@ mod tests {
|
||||
.expect("RestoreObject access should still be waiting at the test hook");
|
||||
|
||||
let (result, req) = access.await.expect("RestoreObject access task should join");
|
||||
result.expect("the already-authorized request should retain its generation guard");
|
||||
result.expect("the equivalent policy should authorize RestoreObject without refreshing its snapshot");
|
||||
let mut opts = crate::storage::ObjectOptions::default();
|
||||
apply_bucket_generation_guard(&req, &bucket, &mut opts).expect("apply the RestoreObject authorization guard");
|
||||
assert_eq!(opts.expected_bucket_incarnation_id, Some(authorized_incarnation_id));
|
||||
|
||||
@@ -125,11 +125,12 @@ pub(crate) use super::sse::{
|
||||
pub(crate) mod access_consumer {
|
||||
pub(crate) use super::super::access::{
|
||||
PostObjectRequestMarker, ReqInfo, TABLE_DATA_PLANE_LIST_CURSOR_PREFIX, TableDataPlaneListAccess,
|
||||
TableDataPlaneListCursorPosition, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard,
|
||||
authorize_internal_object_request, authorize_request, bucket_config_mutation_incarnation, delete_object_authorize_action,
|
||||
has_bypass_governance_header, load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
|
||||
TableDataPlaneListCursorPosition, TableDataPlanePublicationGuards, apply_bucket_generation_guard,
|
||||
apply_copy_source_bucket_generation_guard, authorize_internal_object_request, authorize_request,
|
||||
bucket_config_mutation_incarnation, delete_object_authorize_action, has_bypass_governance_header,
|
||||
load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
|
||||
prepare_list_buckets_iam_authorization, prepare_odm_read_generation, recursive_force_delete_has_authenticated_caller,
|
||||
replication_request_authorized, req_info_mut, req_info_ref,
|
||||
replication_request_authorized, req_info_mut, req_info_ref, retained_table_data_plane_publication_guards,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -976,6 +976,13 @@ pub(crate) enum IcebergStatisticsFileKind {
|
||||
Partition,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct IcebergStatisticsObjectReference {
|
||||
pub(crate) snapshot_id: i64,
|
||||
pub(crate) location: String,
|
||||
pub(crate) kind: IcebergStatisticsFileKind,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_iceberg_statistics_file(
|
||||
value: &serde_json::Value,
|
||||
label: &str,
|
||||
@@ -1073,10 +1080,16 @@ fn statistics_non_negative_i64(
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_table_statistics_references(
|
||||
fn is_supported_partition_statistics_file(data: &[u8]) -> bool {
|
||||
data.starts_with(b"ORC")
|
||||
|| data.starts_with(b"Obj\x01")
|
||||
|| (data.len() >= 8 && data.starts_with(b"PAR1") && data.ends_with(b"PAR1"))
|
||||
}
|
||||
|
||||
pub(crate) fn table_statistics_object_references(
|
||||
metadata: &serde_json::Value,
|
||||
snapshot_ids: &BTreeSet<i64>,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
) -> TableCatalogStoreResult<Vec<IcebergStatisticsObjectReference>> {
|
||||
let mut references = Vec::new();
|
||||
for (field, kind) in [
|
||||
("statistics", IcebergStatisticsFileKind::Table),
|
||||
("partition-statistics", IcebergStatisticsFileKind::Partition),
|
||||
@@ -1087,19 +1100,49 @@ fn validate_table_statistics_references(
|
||||
let values = values
|
||||
.as_array()
|
||||
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("table metadata field {field} must be an array")))?;
|
||||
let mut snapshot_ids_with_statistics = BTreeSet::new();
|
||||
for value in values {
|
||||
let snapshot_id = validate_iceberg_statistics_file(value, field, kind)?;
|
||||
if !snapshot_ids.contains(&snapshot_id) {
|
||||
return Err(TableCatalogStoreError::Invalid(format!(
|
||||
"{field} references missing snapshot {snapshot_id}"
|
||||
)));
|
||||
}
|
||||
if !snapshot_ids_with_statistics.insert(snapshot_id) {
|
||||
return Err(TableCatalogStoreError::Invalid(format!(
|
||||
"{field} contains duplicate entries for snapshot {snapshot_id}"
|
||||
)));
|
||||
}
|
||||
let location = value
|
||||
.get("statistics-path")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|location| !location.is_empty())
|
||||
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{field}.statistics-path must be a non-empty string")))?;
|
||||
references.push(IcebergStatisticsObjectReference {
|
||||
snapshot_id,
|
||||
location: location.to_string(),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(references)
|
||||
}
|
||||
|
||||
fn validate_table_statistics_references(
|
||||
metadata: &serde_json::Value,
|
||||
snapshot_ids: &BTreeSet<i64>,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
let mut table_statistics_snapshot_ids = BTreeSet::new();
|
||||
let mut partition_statistics_snapshot_ids = BTreeSet::new();
|
||||
for reference in table_statistics_object_references(metadata)? {
|
||||
if !snapshot_ids.contains(&reference.snapshot_id) {
|
||||
return Err(TableCatalogStoreError::Invalid(format!(
|
||||
"{} references missing snapshot {}",
|
||||
match reference.kind {
|
||||
IcebergStatisticsFileKind::Table => "statistics",
|
||||
IcebergStatisticsFileKind::Partition => "partition-statistics",
|
||||
},
|
||||
reference.snapshot_id
|
||||
)));
|
||||
}
|
||||
let (field, snapshot_ids_with_statistics) = match reference.kind {
|
||||
IcebergStatisticsFileKind::Table => ("statistics", &mut table_statistics_snapshot_ids),
|
||||
IcebergStatisticsFileKind::Partition => ("partition-statistics", &mut partition_statistics_snapshot_ids),
|
||||
};
|
||||
if !snapshot_ids_with_statistics.insert(reference.snapshot_id) {
|
||||
return Err(TableCatalogStoreError::Invalid(format!(
|
||||
"{field} contains duplicate entries for snapshot {}",
|
||||
reference.snapshot_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -2798,7 +2841,7 @@ fn snapshot_is_retained_v1_history(current_metadata: Option<&serde_json::Value>,
|
||||
current_snapshot == target_snapshot
|
||||
}
|
||||
|
||||
async fn validate_table_statistics_objects<B>(
|
||||
pub(crate) async fn validate_table_statistics_objects<B>(
|
||||
context: &TableSnapshotGraphValidationContext<'_, B>,
|
||||
metadata: &serde_json::Value,
|
||||
) -> TableCatalogStoreResult<()>
|
||||
@@ -2879,12 +2922,17 @@ where
|
||||
}
|
||||
let valid_magic = match kind {
|
||||
IcebergStatisticsFileKind::Table => object.data.starts_with(b"PFA1") && object.data.ends_with(b"PFA1"),
|
||||
IcebergStatisticsFileKind::Partition => object.data.starts_with(b"PAR1") && object.data.ends_with(b"PAR1"),
|
||||
// Iceberg partition statistics use the table's data-file format, not a
|
||||
// fixed Parquet encoding. Validate the format envelope without parsing
|
||||
// the optional statistics rows here.
|
||||
IcebergStatisticsFileKind::Partition => is_supported_partition_statistics_file(&object.data),
|
||||
};
|
||||
if !valid_magic {
|
||||
return Err(TableCatalogStoreError::Invalid(match kind {
|
||||
IcebergStatisticsFileKind::Table => "table statistics object is not a Puffin file".to_string(),
|
||||
IcebergStatisticsFileKind::Partition => "partition statistics object is not a Parquet file".to_string(),
|
||||
IcebergStatisticsFileKind::Partition => {
|
||||
"partition statistics object is not a supported table data file".to_string()
|
||||
}
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -207,6 +207,15 @@ const TABLE_CATALOG_MIGRATION_FENCE_FILE: &str = "durable-strong-fence.json";
|
||||
const TABLE_CATALOG_MIGRATION_FENCE_LOCK: &str = "durable-strong-fence.lock";
|
||||
const TABLE_CATALOG_MIGRATION_GLOBAL_FENCE_FILE: &str = "durable-strong-global-fence.json";
|
||||
const TABLE_CATALOG_MIGRATION_GLOBAL_FENCE_LOCK: &str = "durable-strong-global-fence.lock";
|
||||
pub(crate) const TABLE_CATALOG_BACKUP_VERSION: u16 = 2;
|
||||
const TABLE_CATALOG_BACKUP_ROOT: &str = "backups";
|
||||
const TABLE_CATALOG_BACKUP_RESTORE_ROOT: &str = "restore-intents";
|
||||
const TABLE_CATALOG_BACKUP_RESTORE_INTENT_FILE: &str = "intent.json";
|
||||
const TABLE_CATALOG_BACKUP_MAX_SIZE: usize = 64 * 1024 * 1024;
|
||||
const TABLE_CATALOG_BACKUP_MAX_OBJECTS: usize = 1_000_000;
|
||||
const TABLE_CATALOG_BACKUP_HASH_MAX_SIZE: u64 = 16 * 1024 * 1024;
|
||||
const TABLE_CATALOG_BACKUP_MAX_MAINTENANCE_OBJECT_SIZE: usize = 4 * 1024 * 1024;
|
||||
const TABLE_CATALOG_BACKUP_MAX_MAINTENANCE_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
type CatalogListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
|
||||
type CatalogListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -298,12 +298,21 @@ where
|
||||
) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
let lock_path = self.paths.backing_migration_fence_lock_path(table_bucket);
|
||||
let guard = self.backend.acquire_read_lock(self.catalog_bucket(), &lock_path).await?;
|
||||
self.ensure_object_backed_catalog_write_permit_after_lock(table_bucket)
|
||||
.await?;
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_object_backed_catalog_write_permit_after_lock(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
if self.read_backing_migration_fence(table_bucket).await?.is_some() {
|
||||
return Err(TableCatalogStoreError::Conflict(format!(
|
||||
"object-backed catalog writes are fenced while table bucket {table_bucket} is prepared for durable strong cutover"
|
||||
)));
|
||||
}
|
||||
Ok(guard)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_global_backing_migration_fence(
|
||||
|
||||
@@ -16,10 +16,14 @@ use super::identifier::is_valid_table_metadata_file_name;
|
||||
use super::*;
|
||||
use crate::storage_api::table::{WriteCommitGuard, WriteCompletion};
|
||||
|
||||
mod backup;
|
||||
mod migration;
|
||||
mod object;
|
||||
mod strong;
|
||||
|
||||
pub(crate) use backup::{TableCatalogBackupReport, TableCatalogRestoreReport};
|
||||
#[cfg(test)]
|
||||
pub(crate) use backup::{TableCatalogBackupStatus, TableCatalogRestoreStatus};
|
||||
use migration::{durable_strong_table_catalog_backing_manifest, table_catalog_backing_manifest};
|
||||
pub(crate) use object::ObjectTableCatalogStore;
|
||||
#[cfg(test)]
|
||||
@@ -455,6 +459,15 @@ pub(crate) trait TableCommitPublication: Send + Sync {
|
||||
|
||||
fn holds_table(&self, table_bucket: &str, namespace: &str, table: &str) -> bool;
|
||||
|
||||
/// Whether bucket publication acquires its own migration read permits.
|
||||
fn acquires_catalog_migration_read_permit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn catalog_migration_read_permit_status(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
fn complete(&self);
|
||||
}
|
||||
|
||||
@@ -583,6 +596,7 @@ pub(crate) struct TableCatalogObject {
|
||||
pub(crate) struct TableCatalogObjectMetadata {
|
||||
pub etag: Option<String>,
|
||||
pub mod_time: Option<OffsetDateTime>,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct TableCatalogLockGuard {
|
||||
@@ -614,9 +628,10 @@ impl TableCatalogLockGuard {
|
||||
}
|
||||
|
||||
fn write_namespace(guard: WriteCommitGuard) -> Self {
|
||||
let lock_lost = guard.lock_lost_signal();
|
||||
Self {
|
||||
_guard: Box::new(()),
|
||||
lock_lost: None,
|
||||
lock_lost,
|
||||
write_commit_guard: Some(guard),
|
||||
}
|
||||
}
|
||||
@@ -629,6 +644,40 @@ impl TableCatalogLockGuard {
|
||||
self.lock_lost.as_ref().is_some_and(|signal| signal.is_lost())
|
||||
|| self.write_commit_guard.as_ref().is_some_and(WriteCommitGuard::is_lock_lost)
|
||||
}
|
||||
|
||||
pub(crate) fn lock_lost_signal(&self) -> Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>> {
|
||||
self.lock_lost.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct TableCatalogObjectMutationFence {
|
||||
signals: Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
}
|
||||
|
||||
impl TableCatalogObjectMutationFence {
|
||||
pub(crate) fn from_signals(
|
||||
signals: impl IntoIterator<Item = Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
signals: signals.into_iter().flatten().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_held(&self) -> TableCatalogStoreResult<()> {
|
||||
if self.signals.iter().any(|signal| signal.is_lost()) {
|
||||
return Err(TableCatalogStoreError::Unavailable(
|
||||
"catalog backup or restore lost its fencing lock".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn add_to_object_options(&self, options: &mut ObjectOptions) {
|
||||
for signal in &self.signals {
|
||||
options.add_namespace_lock_lost_signal(Arc::clone(signal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -669,6 +718,13 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
|
||||
None
|
||||
}
|
||||
|
||||
async fn acquire_catalog_migration_read_guards(
|
||||
&self,
|
||||
_table_bucket: &str,
|
||||
) -> TableCatalogStoreResult<Vec<TableCatalogLockGuard>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn read_object(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Option<TableCatalogObject>>;
|
||||
|
||||
async fn read_object_limited(
|
||||
@@ -712,6 +768,7 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
|
||||
.map(|object| TableCatalogObjectMetadata {
|
||||
etag: object.etag,
|
||||
mod_time: object.mod_time,
|
||||
size: u64::try_from(object.data.len()).unwrap_or(u64::MAX),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -726,6 +783,7 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
|
||||
.map(|object| TableCatalogObjectMetadata {
|
||||
etag: object.etag,
|
||||
mod_time: object.mod_time,
|
||||
size: u64::try_from(object.data.len()).unwrap_or(u64::MAX),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -758,8 +816,35 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
|
||||
self.put_object(bucket, object, data, precondition).await
|
||||
}
|
||||
|
||||
async fn put_object_fenced(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: Vec<u8>,
|
||||
precondition: TableCatalogPutPrecondition,
|
||||
fence: &TableCatalogObjectMutationFence,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
fence.ensure_held()?;
|
||||
self.put_object(bucket, object, data, precondition).await?;
|
||||
fence.ensure_held()
|
||||
}
|
||||
|
||||
async fn delete_object(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<()>;
|
||||
|
||||
async fn delete_object_if_match(&self, bucket: &str, object: &str, expected_etag: &str) -> TableCatalogStoreResult<()>;
|
||||
|
||||
async fn delete_object_if_match_fenced(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
expected_etag: &str,
|
||||
fence: &TableCatalogObjectMutationFence,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
fence.ensure_held()?;
|
||||
self.delete_object_if_match(bucket, object, expected_etag).await?;
|
||||
fence.ensure_held()
|
||||
}
|
||||
|
||||
async fn delete_object_unlocked(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<()> {
|
||||
self.delete_object(bucket, object).await
|
||||
}
|
||||
@@ -813,6 +898,15 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether bucket publication acquires its own migration read permits.
|
||||
fn acquires_catalog_migration_read_permit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn catalog_migration_read_permit_status(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
fn complete_table_commit_publication(&self) {}
|
||||
}
|
||||
|
||||
@@ -931,6 +1025,14 @@ where
|
||||
self.table_commit_publication_is_held(table_bucket, namespace, table)
|
||||
}
|
||||
|
||||
fn acquires_catalog_migration_read_permit(&self) -> bool {
|
||||
TableCatalogObjectBackend::acquires_catalog_migration_read_permit(self)
|
||||
}
|
||||
|
||||
fn catalog_migration_read_permit_status(&self) -> Option<bool> {
|
||||
TableCatalogObjectBackend::catalog_migration_read_permit_status(self)
|
||||
}
|
||||
|
||||
fn complete(&self) {
|
||||
self.complete_table_commit_publication();
|
||||
}
|
||||
@@ -1195,6 +1297,26 @@ impl TableCatalogObjectPaths {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn catalog_backup_path(&self, table_bucket: &str, backup_id: &str) -> String {
|
||||
format!(
|
||||
"{}/{}/{}/{}.json",
|
||||
self.catalog_root,
|
||||
TABLE_CATALOG_BACKUP_ROOT,
|
||||
table_catalog_path_hash(table_bucket),
|
||||
table_catalog_path_hash(backup_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn catalog_backup_restore_intent_path(&self, table_bucket: &str) -> String {
|
||||
format!(
|
||||
"{}/{}/{}/{}",
|
||||
self.catalog_root,
|
||||
TABLE_CATALOG_BACKUP_RESTORE_ROOT,
|
||||
table_catalog_path_hash(table_bucket),
|
||||
TABLE_CATALOG_BACKUP_RESTORE_INTENT_FILE
|
||||
)
|
||||
}
|
||||
|
||||
fn table_bucket_root_prefix(&self, table_bucket: &str) -> String {
|
||||
format!("{}/{}/{}/", self.catalog_root, TABLE_BUCKET_ROOT, table_catalog_path_hash(table_bucket))
|
||||
}
|
||||
@@ -1839,6 +1961,42 @@ where
|
||||
Self::DurableStrong(_) => Err(Self::unsupported_for_durable_strong("external catalog bridge")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_durable_catalog_backup(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
expected_snapshot_etag: Option<&str>,
|
||||
) -> TableCatalogStoreResult<TableCatalogBackupReport> {
|
||||
match self {
|
||||
Self::ObjectBacked(_) => Err(TableCatalogStoreError::Unsupported(
|
||||
"durable catalog backup requires durable-strong backing".to_string(),
|
||||
)),
|
||||
Self::DurableStrong(store) => {
|
||||
store
|
||||
.create_durable_catalog_backup(table_bucket, expected_snapshot_etag)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_durable_catalog_backup(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
backup_id: &str,
|
||||
expected_snapshot_etag: Option<&str>,
|
||||
allow_replace: bool,
|
||||
) -> TableCatalogStoreResult<TableCatalogRestoreReport> {
|
||||
match self {
|
||||
Self::ObjectBacked(_) => Err(TableCatalogStoreError::Unsupported(
|
||||
"durable catalog restore requires durable-strong backing".to_string(),
|
||||
)),
|
||||
Self::DurableStrong(store) => {
|
||||
store
|
||||
.restore_durable_catalog_backup(table_bucket, backup_id, expected_snapshot_etag, allow_replace)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct EcStoreTableCatalogObjectBackend<S> {
|
||||
@@ -1875,6 +2033,26 @@ where
|
||||
Some(self.strong_runtime.clone())
|
||||
}
|
||||
|
||||
fn acquires_catalog_migration_read_permit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn acquire_catalog_migration_read_guards(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
) -> TableCatalogStoreResult<Vec<TableCatalogLockGuard>> {
|
||||
// Handlers discover and publish objects through one publication object. Keep the
|
||||
// migration read permits ahead of its publication lock, matching store mutations.
|
||||
let paths = TableCatalogObjectPaths::default();
|
||||
let global = self
|
||||
.acquire_read_lock(RUSTFS_META_BUCKET, &paths.backing_migration_global_fence_lock_path())
|
||||
.await?;
|
||||
let bucket = self
|
||||
.acquire_read_lock(RUSTFS_META_BUCKET, &paths.backing_migration_fence_lock_path(table_bucket))
|
||||
.await?;
|
||||
Ok(vec![global, bucket])
|
||||
}
|
||||
|
||||
async fn read_object(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Option<TableCatalogObject>> {
|
||||
self.read_object_with_options(bucket, object, ObjectOptions::default(), None)
|
||||
.await
|
||||
@@ -1926,6 +2104,7 @@ where
|
||||
Ok(info) => Ok(Some(TableCatalogObjectMetadata {
|
||||
etag: info.etag,
|
||||
mod_time: info.mod_time,
|
||||
size: u64::try_from(info.size.max(0)).unwrap_or(u64::MAX),
|
||||
})),
|
||||
Err(err) if is_missing_storage_error(&err) => Ok(None),
|
||||
Err(err) => Err(storage_error_to_catalog("stat catalog object", err)),
|
||||
@@ -1952,6 +2131,7 @@ where
|
||||
Ok(info) => Ok(Some(TableCatalogObjectMetadata {
|
||||
etag: info.etag,
|
||||
mod_time: info.mod_time,
|
||||
size: u64::try_from(info.size.max(0)).unwrap_or(u64::MAX),
|
||||
})),
|
||||
Err(err) if is_missing_storage_error(&err) => Ok(None),
|
||||
Err(err) => Err(storage_error_to_catalog("stat catalog object", err)),
|
||||
@@ -1992,7 +2172,8 @@ where
|
||||
data: Vec<u8>,
|
||||
precondition: TableCatalogPutPrecondition,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
self.put_object_with_options(bucket, object, data, precondition, None).await
|
||||
self.put_object_with_options(bucket, object, data, precondition, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_object_unlocked(
|
||||
@@ -2003,7 +2184,20 @@ where
|
||||
precondition: TableCatalogPutPrecondition,
|
||||
write_guards: Vec<WriteCommitGuard>,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
self.put_object_with_options(bucket, object, data, precondition, Some(write_guards))
|
||||
self.put_object_with_options(bucket, object, data, precondition, Some(write_guards), None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_object_fenced(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: Vec<u8>,
|
||||
precondition: TableCatalogPutPrecondition,
|
||||
fence: &TableCatalogObjectMutationFence,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
fence.ensure_held()?;
|
||||
self.put_object_with_options(bucket, object, data, precondition, None, Some(fence))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -2015,6 +2209,23 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_object_if_match(&self, bucket: &str, object: &str, expected_etag: &str) -> TableCatalogStoreResult<()> {
|
||||
self.delete_object_if_match_with_fence(bucket, object, expected_etag, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_object_if_match_fenced(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
expected_etag: &str,
|
||||
fence: &TableCatalogObjectMutationFence,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
fence.ensure_held()?;
|
||||
self.delete_object_if_match_with_fence(bucket, object, expected_etag, Some(fence))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_object_unlocked(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<()> {
|
||||
match self
|
||||
.store
|
||||
@@ -2115,6 +2326,37 @@ impl<S> EcStoreTableCatalogObjectBackend<S>
|
||||
where
|
||||
S: TableCatalogStorage,
|
||||
{
|
||||
async fn delete_object_if_match_with_fence(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
expected_etag: &str,
|
||||
fence: Option<&TableCatalogObjectMutationFence>,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
let mut options = ObjectOptions {
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(expected_etag.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(fence) = fence {
|
||||
fence.add_to_object_options(&mut options);
|
||||
}
|
||||
match self.store.delete_object(bucket, object, options).await {
|
||||
Ok(_) => {
|
||||
if let Some(fence) = fence {
|
||||
fence.ensure_held()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) if is_missing_storage_error(&err) => Err(TableCatalogStoreError::Conflict(format!(
|
||||
"delete catalog object: {bucket}/{object} disappeared before conditional cleanup"
|
||||
))),
|
||||
Err(err) => Err(storage_error_to_catalog("conditionally delete catalog object", err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_object_with_options(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -2168,12 +2410,16 @@ where
|
||||
data: Vec<u8>,
|
||||
precondition: TableCatalogPutPrecondition,
|
||||
write_guards: Option<Vec<WriteCommitGuard>>,
|
||||
fence: Option<&TableCatalogObjectMutationFence>,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
let mut reader = PutObjReader::from_vec(data);
|
||||
let mut opts = ObjectOptions {
|
||||
http_preconditions: http_preconditions_for_catalog_put(precondition),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(fence) = fence {
|
||||
fence.add_to_object_options(&mut opts);
|
||||
}
|
||||
if let Some(write_guards) = write_guards {
|
||||
if write_guards.is_empty() {
|
||||
return Err(TableCatalogStoreError::Internal("catalog write has no namespace owner".to_string()));
|
||||
@@ -2183,10 +2429,133 @@ where
|
||||
}
|
||||
opts.write_completion = WriteCompletion::TailDrained;
|
||||
}
|
||||
self.store
|
||||
let result = self
|
||||
.store
|
||||
.put_object(bucket, object, &mut reader, &opts)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| storage_error_to_catalog("write catalog object", err))
|
||||
.map_err(|err| storage_error_to_catalog("write catalog object", err));
|
||||
if result.is_ok()
|
||||
&& let Some(fence) = fence
|
||||
{
|
||||
fence.ensure_held()?;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage_api::protocols::client::{BucketOperations as _, MakeBucketOptions};
|
||||
use rustfs_lock::{LockClient, LockId, LockInfo, LockRequest, LockResponse, LockStats};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingLeaseClient {
|
||||
inner: rustfs_lock::LocalClient,
|
||||
acquired: Mutex<Option<LockId>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LockClient for RecordingLeaseClient {
|
||||
async fn acquire_lock(&self, request: &LockRequest) -> rustfs_lock::Result<LockResponse> {
|
||||
let response = self.inner.acquire_lock(request).await?;
|
||||
if let Some(info) = response.lock_info.as_ref() {
|
||||
*self.acquired.lock().expect("record lease") = Some(info.id.clone());
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
async fn release(&self, id: &LockId) -> rustfs_lock::Result<bool> {
|
||||
self.inner.release(id).await
|
||||
}
|
||||
async fn refresh(&self, id: &LockId) -> rustfs_lock::Result<bool> {
|
||||
self.inner.refresh(id).await
|
||||
}
|
||||
async fn force_release(&self, id: &LockId) -> rustfs_lock::Result<bool> {
|
||||
self.inner.force_release(id).await
|
||||
}
|
||||
async fn check_status(&self, id: &LockId) -> rustfs_lock::Result<Option<LockInfo>> {
|
||||
self.inner.check_status(id).await
|
||||
}
|
||||
async fn get_stats(&self) -> rustfs_lock::Result<LockStats> {
|
||||
self.inner.get_stats().await
|
||||
}
|
||||
async fn close(&self) -> rustfs_lock::Result<()> {
|
||||
self.inner.close().await
|
||||
}
|
||||
async fn is_online(&self) -> bool {
|
||||
self.inner.is_online().await
|
||||
}
|
||||
async fn is_local(&self) -> bool {
|
||||
self.inner.is_local().await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn catalog_writer_loss_prevents_recovery_storage_commit() {
|
||||
let (_temp, _paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
|
||||
let bucket = "catalog-fence-loss-probe";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("make probe bucket");
|
||||
let backend = EcStoreTableCatalogObjectBackend::new_with_strong_runtime(store, StrongTableCatalogRuntime::default());
|
||||
let client = Arc::new(RecordingLeaseClient {
|
||||
inner: rustfs_lock::LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::Enabled(Arc::new(
|
||||
rustfs_lock::FastObjectLockManager::new(),
|
||||
)))),
|
||||
acquired: Mutex::new(None),
|
||||
});
|
||||
let lock = rustfs_lock::NamespaceLockWrapper::new(
|
||||
rustfs_lock::NamespaceLock::with_clients_and_quorum(
|
||||
"catalog-probe".to_string(),
|
||||
vec![client.clone() as Arc<dyn LockClient>],
|
||||
1,
|
||||
),
|
||||
rustfs_lock::ObjectKey::new(bucket, default_table_bucket_publication_lock_path()),
|
||||
"catalog-backup-probe".to_string(),
|
||||
);
|
||||
// These are the exact acquisition and conversion used by the production
|
||||
// EcStoreTableCatalogObjectBackend::acquire_write_lock implementation.
|
||||
let owner = WriteCommitGuard::acquire(&lock, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("acquire distributed writer");
|
||||
let guard = TableCatalogLockGuard::write_namespace(owner);
|
||||
let fence = TableCatalogObjectMutationFence::from_signals([guard.lock_lost_signal()]);
|
||||
assert!(!guard.is_lock_lost(), "models backup's last outer fencing check");
|
||||
let id = client
|
||||
.acquired
|
||||
.lock()
|
||||
.expect("lease recorded")
|
||||
.clone()
|
||||
.expect("actual lease acquired");
|
||||
assert!(client.force_release(&id).await.expect("force-release lease"));
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
while !guard.is_lock_lost() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("actual heartbeat must observe released distributed lease");
|
||||
let result = backend
|
||||
.put_object_fenced(
|
||||
bucket,
|
||||
"recovery-artifact.json",
|
||||
b"stale recovery artifact".to_vec(),
|
||||
TableCatalogPutPrecondition::IfAbsent,
|
||||
&fence,
|
||||
)
|
||||
.await;
|
||||
let persisted = backend
|
||||
.read_object(bucket, "recovery-artifact.json")
|
||||
.await
|
||||
.expect("read real persisted object");
|
||||
assert!(
|
||||
result.is_err() && persisted.is_none(),
|
||||
"lost publication writer must prevent storage commit; result={result:?}, persisted={}",
|
||||
persisted.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ fn validate_external_catalog_bridge_entry_object(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_table_maintenance_report_owner(
|
||||
pub(super) fn validate_table_maintenance_report_owner(
|
||||
report: &TableMetadataMaintenanceReport,
|
||||
table_bucket: &str,
|
||||
namespace: &Namespace,
|
||||
@@ -2168,6 +2168,39 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
async fn begin_table_bucket_with_migration_permit(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<Option<TableCatalogLockGuard>> {
|
||||
let permit_before_publication = publication.catalog_migration_read_permit_status();
|
||||
if permit_before_publication == Some(false) {
|
||||
return Err(TableCatalogStoreError::Conflict(
|
||||
"table-bucket catalog migration read permit was lost".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
let result = match (permit_before_publication, publication.catalog_migration_read_permit_status()) {
|
||||
(Some(true), Some(true)) | (None, Some(true)) => self
|
||||
.ensure_object_backed_catalog_write_permit_after_lock(table_bucket)
|
||||
.await
|
||||
.map(|()| None),
|
||||
(None, None) => self.acquire_object_backed_catalog_write_permit(table_bucket).await.map(Some),
|
||||
(Some(true), Some(false)) | (None, Some(false)) => Err(TableCatalogStoreError::Conflict(
|
||||
"table-bucket catalog migration read permit was lost".to_string(),
|
||||
)),
|
||||
(Some(true), None) => Err(TableCatalogStoreError::Internal(
|
||||
"table-bucket catalog migration read permit disappeared during publication".to_string(),
|
||||
)),
|
||||
(Some(false), _) => unreachable!("a lost migration permit is rejected before publication"),
|
||||
};
|
||||
if result.is_err() {
|
||||
publication.complete();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn write_table_entry_with_publication(
|
||||
&self,
|
||||
entry: TableEntry,
|
||||
@@ -2178,15 +2211,16 @@ where
|
||||
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||
let table = parse_table_for_store(&entry.table)?;
|
||||
validate_table_warehouse_location(&entry.table_bucket, &entry.warehouse_location)?;
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
self.require_table_bucket(&entry.table_bucket).await?;
|
||||
let _migration_guard = self
|
||||
.begin_table_bucket_with_migration_permit(&entry.table_bucket, publication)
|
||||
.await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table registration requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
self.require_table_bucket(&entry.table_bucket).await?;
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(&entry.table_bucket).await?;
|
||||
self.recover_active_table_rename(&entry.table_bucket, publication).await?;
|
||||
let namespace_path = self.paths.namespace_entry_path(&entry.table_bucket, &namespace);
|
||||
let _namespace_guard = self
|
||||
@@ -2274,18 +2308,19 @@ where
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
validate_view_entry_version_and_id(&entry)?;
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
self.require_table_bucket(&entry.table_bucket).await?;
|
||||
let _migration_guard = self
|
||||
.begin_table_bucket_with_migration_permit(&entry.table_bucket, publication)
|
||||
.await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view creation requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
self.require_table_bucket(&entry.table_bucket).await?;
|
||||
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||
let view = parse_table_for_store(&entry.view)?;
|
||||
validate_view_warehouse_location(&entry.table_bucket, &entry.warehouse_location)?;
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(&entry.table_bucket).await?;
|
||||
self.recover_active_table_rename(&entry.table_bucket, publication).await?;
|
||||
let namespace_path = self.paths.namespace_entry_path(&entry.table_bucket, &namespace);
|
||||
let _namespace_guard = self
|
||||
@@ -2527,9 +2562,9 @@ where
|
||||
let namespace = parse_namespace_for_store(namespace)?;
|
||||
let table = parse_table_for_store(table)?;
|
||||
let publication = TableCommitLockPublication::new(&self.backend);
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(table_bucket).await?;
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(&publication);
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(table_bucket).await?;
|
||||
self.recover_active_table_rename(table_bucket, &publication).await?;
|
||||
let table_path = self.paths.table_entry_path(table_bucket, &namespace, &table);
|
||||
let _guard = self.backend.acquire_write_lock(self.catalog_bucket(), &table_path).await?;
|
||||
@@ -5321,6 +5356,7 @@ where
|
||||
let destination_namespace = parse_namespace_for_store(destination_namespace)?;
|
||||
let destination_table = parse_table_for_store(destination_table)?;
|
||||
let publication = TableCommitLockPublication::new(&self.backend);
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(table_bucket).await?;
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
if !publication.holds_table_bucket(table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
@@ -5328,7 +5364,6 @@ where
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(&publication);
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(table_bucket).await?;
|
||||
self.recover_active_table_rename(table_bucket, &publication).await?;
|
||||
|
||||
{
|
||||
@@ -5564,7 +5599,6 @@ where
|
||||
|
||||
async fn commit_table(&self, request: TableCommitRequest) -> TableCatalogStoreResult<TableCommitResult> {
|
||||
let publication = TableCommitLockPublication::new(&self.backend);
|
||||
publication.begin_table_bucket(&request.table_bucket).await?;
|
||||
self.commit_table_with_publication(request, &publication).await
|
||||
}
|
||||
|
||||
@@ -5577,7 +5611,14 @@ where
|
||||
record_table_commit_attempt(&request.operation);
|
||||
let namespace = parse_namespace_for_store(&request.namespace)?;
|
||||
let table = parse_table_for_store(&request.table)?;
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(&request.table_bucket).await?;
|
||||
let _migration_guard = self
|
||||
.begin_table_bucket_with_migration_permit(&request.table_bucket, publication)
|
||||
.await?;
|
||||
if !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table commit requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
if publication.holds_table_bucket(&request.table_bucket) {
|
||||
self.recover_active_table_rename(&request.table_bucket, publication).await?;
|
||||
} else {
|
||||
@@ -5999,10 +6040,10 @@ where
|
||||
|
||||
async fn drop_table(&self, table_bucket: &str, namespace: &str, table: &str) -> TableCatalogStoreResult<()> {
|
||||
let publication = TableCommitLockPublication::new(&self.backend);
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
let namespace = parse_namespace_for_store(namespace)?;
|
||||
let table = parse_table_for_store(table)?;
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(table_bucket).await?;
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
self.recover_active_table_rename(table_bucket, &publication).await?;
|
||||
let namespace_path = self.paths.namespace_entry_path(table_bucket, &namespace);
|
||||
let _namespace_guard = self
|
||||
@@ -6148,15 +6189,29 @@ where
|
||||
) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
let namespace = parse_namespace_for_store(&request.namespace)?;
|
||||
let view = parse_table_for_store(&request.view)?;
|
||||
if table_bucket_fence_required {
|
||||
publication.begin_table_bucket(&request.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
let _migration_guard = if table_bucket_fence_required {
|
||||
self.begin_table_bucket_with_migration_permit(&request.table_bucket, publication)
|
||||
.await?
|
||||
} else {
|
||||
match publication.catalog_migration_read_permit_status() {
|
||||
Some(true) => {
|
||||
self.ensure_object_backed_catalog_write_permit_after_lock(&request.table_bucket)
|
||||
.await?;
|
||||
None
|
||||
}
|
||||
Some(false) => {
|
||||
return Err(TableCatalogStoreError::Conflict(
|
||||
"table-bucket catalog migration read permit was lost".to_string(),
|
||||
));
|
||||
}
|
||||
None => Some(self.acquire_object_backed_catalog_write_permit(&request.table_bucket).await?),
|
||||
}
|
||||
};
|
||||
if table_bucket_fence_required && !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(&request.table_bucket).await?;
|
||||
if publication.holds_table_bucket(&request.table_bucket) {
|
||||
self.recover_active_table_rename(&request.table_bucket, publication).await?;
|
||||
} else {
|
||||
@@ -6282,11 +6337,11 @@ where
|
||||
|
||||
async fn drop_view(&self, table_bucket: &str, namespace: &str, view: &str) -> TableCatalogStoreResult<()> {
|
||||
let publication = TableCommitLockPublication::new(&self.backend);
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(&publication);
|
||||
let namespace = parse_namespace_for_store(namespace)?;
|
||||
let view = parse_table_for_store(view)?;
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(table_bucket).await?;
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(&publication);
|
||||
self.recover_active_table_rename(table_bucket, &publication).await?;
|
||||
let namespace_path = self.paths.namespace_entry_path(table_bucket, &namespace);
|
||||
let _namespace_guard = self
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(in crate::table_catalog) struct StrongTableCatalogState {
|
||||
pub(super) hydrated: bool,
|
||||
snapshot_required: bool,
|
||||
pub(in crate::table_catalog) snapshot_etag: Option<String>,
|
||||
snapshot_version: Option<u16>,
|
||||
pub(super) snapshot_version: Option<u16>,
|
||||
pub(in crate::table_catalog) table_buckets: BTreeMap<String, TableBucketEntry>,
|
||||
pub(in crate::table_catalog) namespaces: BTreeMap<StrongNamespaceKey, NamespaceEntry>,
|
||||
namespace_children: BTreeMap<StrongNamespaceChildKey, String>,
|
||||
@@ -102,7 +102,8 @@ pub(in crate::table_catalog) struct StrongTableCatalogSnapshot {
|
||||
pub(in crate::table_catalog) idempotency: Vec<StrongCommitSnapshotRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(in crate::table_catalog) struct StrongTableCatalogBucketSnapshot {
|
||||
pub(super) table_bucket: TableBucketEntry,
|
||||
pub(super) namespaces: Vec<NamespaceEntry>,
|
||||
@@ -113,7 +114,7 @@ pub(in crate::table_catalog) struct StrongTableCatalogBucketSnapshot {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum StrongSnapshotWritePostcondition {
|
||||
pub(super) enum StrongSnapshotWritePostcondition {
|
||||
TableBucketPresent(TableBucketEntry),
|
||||
TableBucketAbsent(String),
|
||||
NamespacePresent(NamespaceEntry),
|
||||
@@ -297,8 +298,8 @@ pub(in crate::table_catalog) fn table_catalog_bucket_snapshot_fingerprint(
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct StrongTableCatalogStore<B> {
|
||||
object_backend: B,
|
||||
snapshot_write_version: u16,
|
||||
pub(super) object_backend: B,
|
||||
pub(super) snapshot_write_version: u16,
|
||||
snapshot_required_on_start: bool,
|
||||
// Single mutex protecting all catalog state (table_buckets, namespaces, tables, views, commits, idempotency).
|
||||
// This is intentional: many operations require atomic read-modify-write across multiple fields.
|
||||
@@ -309,7 +310,7 @@ pub(crate) struct StrongTableCatalogStore<B> {
|
||||
// 3. Using optimistic concurrency with version checks
|
||||
pub(in crate::table_catalog) state: Arc<tokio::sync::Mutex<StrongTableCatalogState>>,
|
||||
// Serializes local snapshot mutations; object ETags fence independent store instances.
|
||||
write_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub(super) write_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
// Coalesces reloads for clones of one store so only one task reads and decodes a changed snapshot.
|
||||
reload_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
#[cfg(test)]
|
||||
@@ -596,7 +597,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket_snapshot_from_state_locked(
|
||||
pub(super) fn bucket_snapshot_from_state_locked(
|
||||
state: &StrongTableCatalogState,
|
||||
table_bucket: &str,
|
||||
) -> Option<StrongTableCatalogBucketSnapshot> {
|
||||
@@ -646,7 +647,7 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_bucket_from_state_locked(state: &mut StrongTableCatalogState, table_bucket: &str) {
|
||||
pub(super) fn remove_bucket_from_state_locked(state: &mut StrongTableCatalogState, table_bucket: &str) {
|
||||
state.table_buckets.remove(table_bucket);
|
||||
state.namespaces.retain(|(entry_bucket, _), _| entry_bucket != table_bucket);
|
||||
state
|
||||
@@ -960,7 +961,7 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_from_mutated_state_locked(
|
||||
pub(super) fn snapshot_from_mutated_state_locked(
|
||||
state: &mut StrongTableCatalogState,
|
||||
configured_write_version: u16,
|
||||
) -> TableCatalogStoreResult<StrongTableCatalogSnapshot> {
|
||||
@@ -1000,7 +1001,7 @@ where
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn state_from_snapshot(
|
||||
pub(super) fn state_from_snapshot(
|
||||
snapshot: StrongTableCatalogSnapshot,
|
||||
snapshot_etag: Option<String>,
|
||||
) -> TableCatalogStoreResult<StrongTableCatalogState> {
|
||||
@@ -1228,7 +1229,7 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
async fn hydrate_state(&self) -> TableCatalogStoreResult<()> {
|
||||
pub(super) async fn hydrate_state(&self) -> TableCatalogStoreResult<()> {
|
||||
let Some((current_snapshot_etag, current_snapshot_required)) = ({
|
||||
let state = self.state.lock().await;
|
||||
if state.hydrated {
|
||||
@@ -1330,11 +1331,22 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
async fn finalize_snapshot_write(
|
||||
pub(super) async fn finalize_snapshot_write(
|
||||
&self,
|
||||
snapshot: StrongTableCatalogSnapshot,
|
||||
precondition: TableCatalogPutPrecondition,
|
||||
postcondition: StrongSnapshotWritePostcondition,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
self.finalize_snapshot_write_with_fence(snapshot, precondition, postcondition, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn finalize_snapshot_write_with_fence(
|
||||
&self,
|
||||
snapshot: StrongTableCatalogSnapshot,
|
||||
precondition: TableCatalogPutPrecondition,
|
||||
postcondition: StrongSnapshotWritePostcondition,
|
||||
mutation_fence: Option<&TableCatalogObjectMutationFence>,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
let data = serde_json::to_vec(&snapshot)
|
||||
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to encode strong catalog snapshot: {err}")))?;
|
||||
@@ -1343,16 +1355,27 @@ where
|
||||
"durable strong catalog snapshot exceeds the maximum encoded size of {STRONG_TABLE_CATALOG_SNAPSHOT_MAX_SIZE} bytes"
|
||||
)));
|
||||
}
|
||||
match self
|
||||
.object_backend
|
||||
.put_object(RUSTFS_META_BUCKET, &Self::snapshot_object_path(), data, precondition)
|
||||
.await
|
||||
{
|
||||
if let Some(fence) = mutation_fence {
|
||||
fence.ensure_held()?;
|
||||
}
|
||||
let write_result = if let Some(fence) = mutation_fence {
|
||||
self.object_backend
|
||||
.put_object_fenced(RUSTFS_META_BUCKET, &Self::snapshot_object_path(), data, precondition, fence)
|
||||
.await
|
||||
} else {
|
||||
self.object_backend
|
||||
.put_object(RUSTFS_META_BUCKET, &Self::snapshot_object_path(), data, precondition)
|
||||
.await
|
||||
};
|
||||
match write_result {
|
||||
Ok(()) => {
|
||||
self.state.lock().await.snapshot_required = true;
|
||||
if let Err(err) = self.reload_state_from_durable().await {
|
||||
self.mark_snapshot_reload_failed(&err, "confirmed-write").await;
|
||||
}
|
||||
if let Some(fence) = mutation_fence {
|
||||
fence.ensure_held()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err @ TableCatalogStoreError::Conflict(_)) => {
|
||||
@@ -1360,12 +1383,18 @@ where
|
||||
if let Err(reload_err) = self.reload_state_from_durable().await {
|
||||
self.mark_snapshot_reload_failed(&reload_err, "write-conflict").await;
|
||||
}
|
||||
if let Some(fence) = mutation_fence {
|
||||
fence.ensure_held()?;
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
Err(err) => {
|
||||
self.state.lock().await.snapshot_required = true;
|
||||
match self.reload_state_from_durable().await {
|
||||
Ok(()) => {
|
||||
if let Some(fence) = mutation_fence {
|
||||
fence.ensure_held()?;
|
||||
}
|
||||
let state = self.state.lock().await;
|
||||
if postcondition.is_satisfied_by::<B>(&state) {
|
||||
Ok(())
|
||||
@@ -1496,7 +1525,7 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn bucket_snapshot_observation(
|
||||
pub(crate) async fn bucket_snapshot_observation(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
) -> TableCatalogStoreResult<(Option<String>, Option<String>)> {
|
||||
@@ -1591,7 +1620,7 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn table_commit_recovery_report_for_entry_locked(
|
||||
pub(super) fn table_commit_recovery_report_for_entry_locked(
|
||||
state: &StrongTableCatalogState,
|
||||
entry: &TableEntry,
|
||||
) -> TableCommitRecoveryReport {
|
||||
@@ -2381,6 +2410,16 @@ where
|
||||
"table metadata location must be inside the table metadata directory".to_string(),
|
||||
));
|
||||
}
|
||||
let _migration_guard = match publication.catalog_migration_read_permit_status() {
|
||||
Some(true) => None,
|
||||
Some(false) => {
|
||||
return Err(TableCatalogStoreError::Conflict(
|
||||
"table-bucket catalog migration read permit was lost".to_string(),
|
||||
));
|
||||
}
|
||||
None if publication.acquires_catalog_migration_read_permit() => None,
|
||||
None => Some(self.acquire_snapshot_write_permit().await?),
|
||||
};
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
@@ -2388,7 +2427,6 @@ where
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
publication
|
||||
.prepare(&entry.table_bucket, &entry.namespace, &entry.table)
|
||||
.await?;
|
||||
@@ -2536,6 +2574,7 @@ where
|
||||
destination_table: &str,
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
let publication = TableCommitLockPublication::new(&self.object_backend);
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
if !publication.holds_table_bucket(table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
@@ -2543,7 +2582,6 @@ where
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(&publication);
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
let _write_guard = self.write_lock.lock().await;
|
||||
self.hydrate_state().await?;
|
||||
|
||||
@@ -2677,7 +2715,6 @@ where
|
||||
|
||||
async fn commit_table(&self, request: TableCommitRequest) -> TableCatalogStoreResult<TableCommitResult> {
|
||||
let publication = TableCommitLockPublication::new(&self.object_backend);
|
||||
publication.begin_table_bucket(&request.table_bucket).await?;
|
||||
self.commit_table_with_publication(request, &publication).await
|
||||
}
|
||||
|
||||
@@ -2686,11 +2723,26 @@ where
|
||||
request: TableCommitRequest,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<TableCommitResult> {
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
let _migration_guard = match publication.catalog_migration_read_permit_status() {
|
||||
Some(true) => None,
|
||||
Some(false) => {
|
||||
return Err(TableCatalogStoreError::Conflict(
|
||||
"table-bucket catalog migration read permit was lost".to_string(),
|
||||
));
|
||||
}
|
||||
None if publication.acquires_catalog_migration_read_permit() => None,
|
||||
None => Some(self.acquire_snapshot_write_permit().await?),
|
||||
};
|
||||
let commit_started = Instant::now();
|
||||
record_table_commit_attempt(&request.operation);
|
||||
let namespace = parse_namespace_for_store(&request.namespace)?;
|
||||
let table = parse_table_for_store(&request.table)?;
|
||||
publication.begin_table_bucket(&request.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table commit requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
publication
|
||||
.prepare(&request.table_bucket, &request.namespace, &request.table)
|
||||
.await?;
|
||||
@@ -2876,8 +2928,8 @@ where
|
||||
|
||||
async fn drop_table(&self, table_bucket: &str, namespace: &str, table: &str) -> TableCatalogStoreResult<()> {
|
||||
let publication = TableCommitLockPublication::new(&self.object_backend);
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
publication.begin_table_bucket(table_bucket).await?;
|
||||
publication.prepare(table_bucket, namespace, table).await?;
|
||||
if !publication.holds_table_bucket(table_bucket) || !publication.holds_table(table_bucket, namespace, table) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
@@ -2959,6 +3011,16 @@ where
|
||||
"view metadata location must be inside the view metadata directory".to_string(),
|
||||
));
|
||||
}
|
||||
let _migration_guard = match publication.catalog_migration_read_permit_status() {
|
||||
Some(true) => None,
|
||||
Some(false) => {
|
||||
return Err(TableCatalogStoreError::Conflict(
|
||||
"table-bucket catalog migration read permit was lost".to_string(),
|
||||
));
|
||||
}
|
||||
None if publication.acquires_catalog_migration_read_permit() => None,
|
||||
None => Some(self.acquire_snapshot_write_permit().await?),
|
||||
};
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
@@ -2966,7 +3028,6 @@ where
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
publication
|
||||
.prepare(&entry.table_bucket, &entry.namespace, &entry.view)
|
||||
.await?;
|
||||
@@ -3083,6 +3144,16 @@ where
|
||||
table_bucket_fence_required: bool,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
let _migration_guard = match publication.catalog_migration_read_permit_status() {
|
||||
Some(true) => None,
|
||||
Some(false) => {
|
||||
return Err(TableCatalogStoreError::Conflict(
|
||||
"table-bucket catalog migration read permit was lost".to_string(),
|
||||
));
|
||||
}
|
||||
None if table_bucket_fence_required && publication.acquires_catalog_migration_read_permit() => None,
|
||||
None => Some(self.acquire_snapshot_write_permit().await?),
|
||||
};
|
||||
if table_bucket_fence_required {
|
||||
publication.begin_table_bucket(&request.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&request.table_bucket) {
|
||||
@@ -3092,7 +3163,6 @@ where
|
||||
}
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
let namespace = parse_namespace_for_store(&request.namespace)?;
|
||||
let view = parse_table_for_store(&request.view)?;
|
||||
publication
|
||||
|
||||
@@ -819,6 +819,7 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
|
||||
Ok(state.objects.get(&key).map(|record| TableCatalogObjectMetadata {
|
||||
etag: (!etagless).then(|| record.etag.clone()),
|
||||
mod_time: record.mod_time,
|
||||
size: record.data.len() as u64,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -958,6 +959,49 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_object_if_match(&self, bucket: &str, object: &str, expected_etag: &str) -> TableCatalogStoreResult<()> {
|
||||
let mut state = self.state.lock().await;
|
||||
let key = (bucket.to_string(), object.to_string());
|
||||
let attempt = {
|
||||
let attempts = state.delete_attempts.entry(key.clone()).or_default();
|
||||
*attempts += 1;
|
||||
*attempts
|
||||
};
|
||||
if state
|
||||
.fail_delete_attempts
|
||||
.get(&key)
|
||||
.is_some_and(|attempts| attempts.contains(&attempt))
|
||||
{
|
||||
return Err(TableCatalogStoreError::Internal(format!(
|
||||
"injected delete failure for {object} attempt {attempt}"
|
||||
)));
|
||||
}
|
||||
match state.objects.get(&key) {
|
||||
None => {
|
||||
return Err(TableCatalogStoreError::Conflict(format!(
|
||||
"conditionally delete catalog object: {bucket}/{object} disappeared before cleanup"
|
||||
)));
|
||||
}
|
||||
Some(record) if record.etag != expected_etag => {
|
||||
return Err(TableCatalogStoreError::Conflict(format!(
|
||||
"conditionally delete catalog object: {bucket}/{object} changed before cleanup"
|
||||
)));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
state.objects.remove(&key);
|
||||
if state
|
||||
.fail_after_delete_attempts
|
||||
.get(&key)
|
||||
.is_some_and(|attempts| attempts.contains(&attempt))
|
||||
{
|
||||
return Err(TableCatalogStoreError::Internal(format!(
|
||||
"injected post-commit delete failure for {object} attempt {attempt}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_objects(&self, bucket: &str, prefix: &str) -> TableCatalogStoreResult<Vec<String>> {
|
||||
let mut state = self.state.lock().await;
|
||||
state.list_calls += 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -312,6 +312,12 @@ The smoke test also probes catalog-backed advanced Iceberg surfaces:
|
||||
and snapshot refs remain protected. Orphan candidates are read-only observations,
|
||||
not deletion authorization. Object-backed reports share one recovery observation
|
||||
but do not provide a cross-object linearizable snapshot.
|
||||
diagnostics fail closed if the strong snapshot changes during inspection;
|
||||
until strong maintenance configuration is persisted, diagnostics use a
|
||||
conservative zero-file retention default. Durable catalog backup and restore
|
||||
are controlled durable-strong operations; backup artifacts record catalog and
|
||||
referenced-object watermarks, while restore verifies them and retains a
|
||||
recoverable intent when maintenance-state finalization is interrupted.
|
||||
|
||||
## Client Matrix
|
||||
|
||||
|
||||
Reference in New Issue
Block a user