mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 00:17:11 +00:00
feat(table-catalog): add durable strong backing (#3836)
This commit is contained in:
+581
-31
@@ -115,6 +115,9 @@ const ICEBERG_MAX_REF_AGE_MS_PROPERTY: &str = "history.expire.max-ref-age-ms";
|
|||||||
const ICEBERG_REF_MIN_SNAPSHOTS_TO_KEEP_FIELD: &str = "min-snapshots-to-keep";
|
const ICEBERG_REF_MIN_SNAPSHOTS_TO_KEEP_FIELD: &str = "min-snapshots-to-keep";
|
||||||
const ICEBERG_REF_MAX_SNAPSHOT_AGE_MS_FIELD: &str = "max-snapshot-age-ms";
|
const ICEBERG_REF_MAX_SNAPSHOT_AGE_MS_FIELD: &str = "max-snapshot-age-ms";
|
||||||
const ICEBERG_REF_MAX_REF_AGE_MS_FIELD: &str = "max-ref-age-ms";
|
const ICEBERG_REF_MAX_REF_AGE_MS_FIELD: &str = "max-ref-age-ms";
|
||||||
|
const STRONG_TABLE_CATALOG_SNAPSHOT_VERSION: u16 = 1;
|
||||||
|
const STRONG_TABLE_CATALOG_BACKING_ROOT: &str = "strong-backing";
|
||||||
|
const STRONG_TABLE_CATALOG_SNAPSHOT_FILE: &str = "snapshot.json";
|
||||||
|
|
||||||
type CatalogListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
|
type CatalogListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
|
||||||
type CatalogListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
type CatalogListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||||
@@ -1760,16 +1763,14 @@ fn table_catalog_backing_manifest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
type StrongNamespaceKey = (String, String);
|
type StrongNamespaceKey = (String, String);
|
||||||
#[cfg(test)]
|
|
||||||
type StrongResourceKey = (String, String, String);
|
type StrongResourceKey = (String, String, String);
|
||||||
#[cfg(test)]
|
|
||||||
type StrongCommitKey = (String, String, String);
|
type StrongCommitKey = (String, String, String);
|
||||||
|
|
||||||
#[cfg(test)]
|
#[derive(Clone, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
struct StrongTableCatalogState {
|
struct StrongTableCatalogState {
|
||||||
|
hydrated: bool,
|
||||||
|
snapshot_etag: Option<String>,
|
||||||
table_buckets: BTreeMap<String, TableBucketEntry>,
|
table_buckets: BTreeMap<String, TableBucketEntry>,
|
||||||
namespaces: BTreeMap<StrongNamespaceKey, NamespaceEntry>,
|
namespaces: BTreeMap<StrongNamespaceKey, NamespaceEntry>,
|
||||||
tables: BTreeMap<StrongResourceKey, TableEntry>,
|
tables: BTreeMap<StrongResourceKey, TableEntry>,
|
||||||
@@ -1778,14 +1779,33 @@ struct StrongTableCatalogState {
|
|||||||
idempotency: BTreeMap<StrongCommitKey, CommitLogEntry>,
|
idempotency: BTreeMap<StrongCommitKey, CommitLogEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct StrongCommitSnapshotRecord {
|
||||||
|
table_bucket: String,
|
||||||
|
table_id: String,
|
||||||
|
lookup_key: String,
|
||||||
|
commit: CommitLogEntry,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct StrongTableCatalogSnapshot {
|
||||||
|
version: u16,
|
||||||
|
table_buckets: Vec<TableBucketEntry>,
|
||||||
|
namespaces: Vec<NamespaceEntry>,
|
||||||
|
tables: Vec<TableEntry>,
|
||||||
|
views: Vec<ViewEntry>,
|
||||||
|
commits: Vec<StrongCommitSnapshotRecord>,
|
||||||
|
idempotency: Vec<StrongCommitSnapshotRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct StrongTableCatalogStore<B> {
|
pub(crate) struct StrongTableCatalogStore<B> {
|
||||||
object_backend: B,
|
object_backend: B,
|
||||||
state: Arc<tokio::sync::Mutex<StrongTableCatalogState>>,
|
state: Arc<tokio::sync::Mutex<StrongTableCatalogState>>,
|
||||||
|
// Serializes local snapshot mutations; object ETags fence independent store instances.
|
||||||
|
write_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl<B> StrongTableCatalogStore<B>
|
impl<B> StrongTableCatalogStore<B>
|
||||||
where
|
where
|
||||||
B: TableCatalogObjectBackend,
|
B: TableCatalogObjectBackend,
|
||||||
@@ -1794,6 +1814,7 @@ where
|
|||||||
Self {
|
Self {
|
||||||
object_backend,
|
object_backend,
|
||||||
state: Arc::new(tokio::sync::Mutex::new(StrongTableCatalogState::default())),
|
state: Arc::new(tokio::sync::Mutex::new(StrongTableCatalogState::default())),
|
||||||
|
write_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1813,6 +1834,160 @@ where
|
|||||||
(table_bucket.to_string(), table_id.to_string(), idempotency_key.to_string())
|
(table_bucket.to_string(), table_id.to_string(), idempotency_key.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn snapshot_object_path() -> String {
|
||||||
|
format!("{INTERNAL_CATALOG_ROOT}/{STRONG_TABLE_CATALOG_BACKING_ROOT}/{STRONG_TABLE_CATALOG_SNAPSHOT_FILE}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_from_state_locked(state: &StrongTableCatalogState) -> StrongTableCatalogSnapshot {
|
||||||
|
StrongTableCatalogSnapshot {
|
||||||
|
version: STRONG_TABLE_CATALOG_SNAPSHOT_VERSION,
|
||||||
|
table_buckets: state.table_buckets.values().cloned().collect(),
|
||||||
|
namespaces: state.namespaces.values().cloned().collect(),
|
||||||
|
tables: state.tables.values().cloned().collect(),
|
||||||
|
views: state.views.values().cloned().collect(),
|
||||||
|
commits: state
|
||||||
|
.commits
|
||||||
|
.iter()
|
||||||
|
.map(|((table_bucket, table_id, lookup_key), commit)| StrongCommitSnapshotRecord {
|
||||||
|
table_bucket: table_bucket.clone(),
|
||||||
|
table_id: table_id.clone(),
|
||||||
|
lookup_key: lookup_key.clone(),
|
||||||
|
commit: commit.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
idempotency: state
|
||||||
|
.idempotency
|
||||||
|
.iter()
|
||||||
|
.map(|((table_bucket, table_id, lookup_key), commit)| StrongCommitSnapshotRecord {
|
||||||
|
table_bucket: table_bucket.clone(),
|
||||||
|
table_id: table_id.clone(),
|
||||||
|
lookup_key: lookup_key.clone(),
|
||||||
|
commit: commit.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_from_snapshot(
|
||||||
|
snapshot: StrongTableCatalogSnapshot,
|
||||||
|
snapshot_etag: Option<String>,
|
||||||
|
) -> TableCatalogStoreResult<StrongTableCatalogState> {
|
||||||
|
if snapshot.version != STRONG_TABLE_CATALOG_SNAPSHOT_VERSION {
|
||||||
|
return Err(TableCatalogStoreError::Invalid(format!(
|
||||||
|
"unsupported strong catalog snapshot version: {}",
|
||||||
|
snapshot.version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = StrongTableCatalogState {
|
||||||
|
hydrated: true,
|
||||||
|
snapshot_etag,
|
||||||
|
..StrongTableCatalogState::default()
|
||||||
|
};
|
||||||
|
for entry in snapshot.table_buckets {
|
||||||
|
state.table_buckets.insert(entry.table_bucket.clone(), entry);
|
||||||
|
}
|
||||||
|
for entry in snapshot.namespaces {
|
||||||
|
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||||
|
state
|
||||||
|
.namespaces
|
||||||
|
.insert(Self::namespace_key(&entry.table_bucket, &namespace), entry);
|
||||||
|
}
|
||||||
|
for entry in snapshot.tables {
|
||||||
|
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||||
|
let table = parse_table_for_store(&entry.table)?;
|
||||||
|
state
|
||||||
|
.tables
|
||||||
|
.insert(Self::table_key(&entry.table_bucket, &namespace, &table), entry);
|
||||||
|
}
|
||||||
|
for entry in snapshot.views {
|
||||||
|
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||||
|
let view = parse_table_for_store(&entry.view)?;
|
||||||
|
state
|
||||||
|
.views
|
||||||
|
.insert(Self::table_key(&entry.table_bucket, &namespace, &view), entry);
|
||||||
|
}
|
||||||
|
for record in snapshot.commits {
|
||||||
|
state.commits.insert(
|
||||||
|
Self::commit_key(&record.table_bucket, &record.table_id, &record.lookup_key),
|
||||||
|
record.commit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for record in snapshot.idempotency {
|
||||||
|
state.idempotency.insert(
|
||||||
|
Self::idempotency_key(&record.table_bucket, &record.table_id, &record.lookup_key),
|
||||||
|
record.commit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_write_precondition_locked(state: &StrongTableCatalogState) -> TableCatalogPutPrecondition {
|
||||||
|
state
|
||||||
|
.snapshot_etag
|
||||||
|
.as_ref()
|
||||||
|
.map_or(TableCatalogPutPrecondition::IfAbsent, |etag| {
|
||||||
|
TableCatalogPutPrecondition::IfMatch(etag.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_write_context_locked(state: &StrongTableCatalogState) -> (TableCatalogPutPrecondition, StrongTableCatalogState) {
|
||||||
|
(Self::snapshot_write_precondition_locked(state), state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hydrate_state(&self) -> TableCatalogStoreResult<()> {
|
||||||
|
self.reload_state_from_durable().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reload_state_from_durable(&self) -> TableCatalogStoreResult<()> {
|
||||||
|
let snapshot_object = self
|
||||||
|
.object_backend
|
||||||
|
.read_object(RUSTFS_META_BUCKET, &Self::snapshot_object_path())
|
||||||
|
.await?;
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
if let Some(snapshot_object) = snapshot_object {
|
||||||
|
let snapshot = serde_json::from_slice::<StrongTableCatalogSnapshot>(&snapshot_object.data)
|
||||||
|
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to decode strong catalog snapshot: {err}")))?;
|
||||||
|
*state = Self::state_from_snapshot(snapshot, snapshot_object.etag)?;
|
||||||
|
} else {
|
||||||
|
*state = StrongTableCatalogState {
|
||||||
|
hydrated: true,
|
||||||
|
..StrongTableCatalogState::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_snapshot(
|
||||||
|
&self,
|
||||||
|
snapshot: StrongTableCatalogSnapshot,
|
||||||
|
precondition: TableCatalogPutPrecondition,
|
||||||
|
) -> TableCatalogStoreResult<()> {
|
||||||
|
let data = serde_json::to_vec(&snapshot)
|
||||||
|
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to encode strong catalog snapshot: {err}")))?;
|
||||||
|
self.object_backend
|
||||||
|
.put_object(RUSTFS_META_BUCKET, &Self::snapshot_object_path(), data, precondition)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finalize_snapshot_write(
|
||||||
|
&self,
|
||||||
|
snapshot: StrongTableCatalogSnapshot,
|
||||||
|
precondition: TableCatalogPutPrecondition,
|
||||||
|
rollback_state: StrongTableCatalogState,
|
||||||
|
) -> TableCatalogStoreResult<()> {
|
||||||
|
match self.persist_snapshot(snapshot, precondition).await {
|
||||||
|
Ok(()) => self.reload_state_from_durable().await,
|
||||||
|
Err(err) => {
|
||||||
|
if self.reload_state_from_durable().await.is_err() {
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
*state = rollback_state;
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn require_table_bucket_in_state(state: &StrongTableCatalogState, table_bucket: &str) -> TableCatalogStoreResult<()> {
|
fn require_table_bucket_in_state(state: &StrongTableCatalogState, table_bucket: &str) -> TableCatalogStoreResult<()> {
|
||||||
if !state.table_buckets.contains_key(table_bucket) {
|
if !state.table_buckets.contains_key(table_bucket) {
|
||||||
return Err(TableCatalogStoreError::NotFound(format!("table bucket {table_bucket}")));
|
return Err(TableCatalogStoreError::NotFound(format!("table bucket {table_bucket}")));
|
||||||
@@ -2026,6 +2201,7 @@ where
|
|||||||
namespace: &str,
|
namespace: &str,
|
||||||
table: &str,
|
table: &str,
|
||||||
) -> TableCatalogStoreResult<TableCommitRecoveryReport> {
|
) -> TableCatalogStoreResult<TableCommitRecoveryReport> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let table = parse_table_for_store(table)?;
|
let table = parse_table_for_store(table)?;
|
||||||
let key = Self::table_key(table_bucket, &namespace, &table);
|
let key = Self::table_key(table_bucket, &namespace, &table);
|
||||||
@@ -2042,18 +2218,20 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl<B> TableCatalogStore for StrongTableCatalogStore<B>
|
impl<B> TableCatalogStore for StrongTableCatalogStore<B>
|
||||||
where
|
where
|
||||||
B: TableCatalogObjectBackend,
|
B: TableCatalogObjectBackend,
|
||||||
{
|
{
|
||||||
async fn get_table_bucket(&self, table_bucket: &str) -> TableCatalogStoreResult<Option<TableBucketEntry>> {
|
async fn get_table_bucket(&self, table_bucket: &str) -> TableCatalogStoreResult<Option<TableBucketEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
Ok(state.table_buckets.get(table_bucket).cloned())
|
Ok(state.table_buckets.get(table_bucket).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn put_table_bucket(&self, entry: TableBucketEntry) -> TableCatalogStoreResult<()> {
|
async fn put_table_bucket(&self, entry: TableBucketEntry) -> TableCatalogStoreResult<()> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
validate_catalog_entry_version("table bucket", entry.version)?;
|
validate_catalog_entry_version("table bucket", entry.version)?;
|
||||||
if entry.table_bucket.is_empty() {
|
if entry.table_bucket.is_empty() {
|
||||||
return Err(TableCatalogStoreError::Invalid("table bucket name cannot be empty".to_string()));
|
return Err(TableCatalogStoreError::Invalid("table bucket name cannot be empty".to_string()));
|
||||||
@@ -2062,15 +2240,22 @@ where
|
|||||||
return Err(TableCatalogStoreError::Invalid("unsupported table bucket catalog type".to_string()));
|
return Err(TableCatalogStoreError::Invalid("unsupported table bucket catalog type".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (snapshot, precondition, rollback_state) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
state.table_buckets.insert(entry.table_bucket.clone(), entry);
|
state.table_buckets.insert(entry.table_bucket.clone(), entry);
|
||||||
Ok(())
|
(Self::snapshot_from_state_locked(&state), precondition, rollback_state)
|
||||||
|
};
|
||||||
|
self.finalize_snapshot_write(snapshot, precondition, rollback_state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_namespace(&self, entry: NamespaceEntry) -> TableCatalogStoreResult<()> {
|
async fn create_namespace(&self, entry: NamespaceEntry) -> TableCatalogStoreResult<()> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
validate_catalog_entry_version("namespace", entry.version)?;
|
validate_catalog_entry_version("namespace", entry.version)?;
|
||||||
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||||
let key = Self::namespace_key(&entry.table_bucket, &namespace);
|
let key = Self::namespace_key(&entry.table_bucket, &namespace);
|
||||||
|
let (snapshot, precondition, rollback_state) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
Self::require_table_bucket_in_state(&state, &entry.table_bucket)?;
|
Self::require_table_bucket_in_state(&state, &entry.table_bucket)?;
|
||||||
if state.namespaces.contains_key(&key) {
|
if state.namespaces.contains_key(&key) {
|
||||||
@@ -2079,11 +2264,15 @@ where
|
|||||||
entry.table_bucket, entry.namespace
|
entry.table_bucket, entry.namespace
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
state.namespaces.insert(key, entry);
|
state.namespaces.insert(key, entry);
|
||||||
Ok(())
|
(Self::snapshot_from_state_locked(&state), precondition, rollback_state)
|
||||||
|
};
|
||||||
|
self.finalize_snapshot_write(snapshot, precondition, rollback_state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_namespaces(&self, table_bucket: &str) -> TableCatalogStoreResult<Vec<NamespaceEntry>> {
|
async fn list_namespaces(&self, table_bucket: &str) -> TableCatalogStoreResult<Vec<NamespaceEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
let mut entries = state
|
let mut entries = state
|
||||||
.namespaces
|
.namespaces
|
||||||
@@ -2096,14 +2285,18 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_namespace(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Option<NamespaceEntry>> {
|
async fn get_namespace(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Option<NamespaceEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
Ok(state.namespaces.get(&Self::namespace_key(table_bucket, &namespace)).cloned())
|
Ok(state.namespaces.get(&Self::namespace_key(table_bucket, &namespace)).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drop_namespace(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<()> {
|
async fn drop_namespace(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<()> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let key = Self::namespace_key(table_bucket, &namespace);
|
let key = Self::namespace_key(table_bucket, &namespace);
|
||||||
|
let (snapshot, precondition, rollback_state) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
if !state.namespaces.contains_key(&key) {
|
if !state.namespaces.contains_key(&key) {
|
||||||
return Err(TableCatalogStoreError::NotFound(format!(
|
return Err(TableCatalogStoreError::NotFound(format!(
|
||||||
@@ -2127,8 +2320,11 @@ where
|
|||||||
namespace.public_name()
|
namespace.public_name()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
state.namespaces.remove(&key);
|
state.namespaces.remove(&key);
|
||||||
Ok(())
|
(Self::snapshot_from_state_locked(&state), precondition, rollback_state)
|
||||||
|
};
|
||||||
|
self.finalize_snapshot_write(snapshot, precondition, rollback_state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_table(&self, entry: TableEntry) -> TableCatalogStoreResult<()> {
|
async fn create_table(&self, entry: TableEntry) -> TableCatalogStoreResult<()> {
|
||||||
@@ -2136,11 +2332,14 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn register_table(&self, entry: TableEntry) -> TableCatalogStoreResult<()> {
|
async fn register_table(&self, entry: TableEntry) -> TableCatalogStoreResult<()> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
validate_catalog_entry_version("table", entry.version)?;
|
validate_catalog_entry_version("table", entry.version)?;
|
||||||
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||||
let table = parse_table_for_store(&entry.table)?;
|
let table = parse_table_for_store(&entry.table)?;
|
||||||
table_warehouse_object_prefix(&entry)?;
|
table_warehouse_object_prefix(&entry)?;
|
||||||
let key = Self::table_key(&entry.table_bucket, &namespace, &table);
|
let key = Self::table_key(&entry.table_bucket, &namespace, &table);
|
||||||
|
let (snapshot, precondition, rollback_state) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
Self::require_table_bucket_in_state(&state, &entry.table_bucket)?;
|
Self::require_table_bucket_in_state(&state, &entry.table_bucket)?;
|
||||||
if !state
|
if !state
|
||||||
@@ -2158,11 +2357,15 @@ where
|
|||||||
entry.table_bucket, entry.namespace, entry.table
|
entry.table_bucket, entry.namespace, entry.table
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
state.tables.insert(key, entry);
|
state.tables.insert(key, entry);
|
||||||
Ok(())
|
(Self::snapshot_from_state_locked(&state), precondition, rollback_state)
|
||||||
|
};
|
||||||
|
self.finalize_snapshot_write(snapshot, precondition, rollback_state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_tables(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Vec<TableEntry>> {
|
async fn list_tables(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Vec<TableEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
let mut entries = state
|
let mut entries = state
|
||||||
@@ -2176,6 +2379,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_table(&self, table_bucket: &str, namespace: &str, table: &str) -> TableCatalogStoreResult<Option<TableEntry>> {
|
async fn load_table(&self, table_bucket: &str, namespace: &str, table: &str) -> TableCatalogStoreResult<Option<TableEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let table = parse_table_for_store(table)?;
|
let table = parse_table_for_store(table)?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
@@ -2183,27 +2387,25 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn commit_table(&self, request: TableCommitRequest) -> TableCatalogStoreResult<TableCommitResult> {
|
async fn commit_table(&self, request: TableCommitRequest) -> TableCatalogStoreResult<TableCommitResult> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
let commit_started = Instant::now();
|
let commit_started = Instant::now();
|
||||||
record_table_commit_attempt(&request.operation);
|
record_table_commit_attempt(&request.operation);
|
||||||
let namespace = parse_namespace_for_store(&request.namespace)?;
|
let namespace = parse_namespace_for_store(&request.namespace)?;
|
||||||
let table = parse_table_for_store(&request.table)?;
|
let table = parse_table_for_store(&request.table)?;
|
||||||
let key = Self::table_key(&request.table_bucket, &namespace, &table);
|
let key = Self::table_key(&request.table_bucket, &namespace, &table);
|
||||||
|
|
||||||
{
|
let committed_existing_result = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
let current = Self::validate_new_table_commit_locked(&state, &key, &request, &namespace, &table);
|
let current = Self::validate_new_table_commit_locked(&state, &key, &request, &namespace, &table);
|
||||||
match current {
|
match current {
|
||||||
Ok(current) => {
|
Ok(current) => {
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
if let Some(result) = Self::committed_existing_result_locked(&mut state, &request, current) {
|
if let Some(result) = Self::committed_existing_result_locked(&mut state, &request, current) {
|
||||||
return table_commit_result(
|
let snapshot = Self::snapshot_from_state_locked(&state);
|
||||||
&request.table_bucket,
|
Some((result, snapshot, precondition, rollback_state))
|
||||||
&request.namespace,
|
} else {
|
||||||
&request.table,
|
None
|
||||||
&request.commit_id,
|
|
||||||
&request.operation,
|
|
||||||
commit_started,
|
|
||||||
Ok(result),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -2218,6 +2420,21 @@ where
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if let Some((result, snapshot, precondition, rollback_state)) = committed_existing_result {
|
||||||
|
let result = self
|
||||||
|
.finalize_snapshot_write(snapshot, precondition, rollback_state)
|
||||||
|
.await
|
||||||
|
.map(|_| result);
|
||||||
|
return table_commit_result(
|
||||||
|
&request.table_bucket,
|
||||||
|
&request.namespace,
|
||||||
|
&request.table,
|
||||||
|
&request.commit_id,
|
||||||
|
&request.operation,
|
||||||
|
commit_started,
|
||||||
|
result,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(new_metadata_object) = self
|
let Some(new_metadata_object) = self
|
||||||
@@ -2242,9 +2459,21 @@ where
|
|||||||
metadata_warehouse_location(&request.table_bucket, &request.new_metadata_location, &new_metadata_object)?;
|
metadata_warehouse_location(&request.table_bucket, &request.new_metadata_location, &new_metadata_object)?;
|
||||||
|
|
||||||
let cas_started = Instant::now();
|
let cas_started = Instant::now();
|
||||||
let result = {
|
let (result, snapshot, precondition, rollback_state) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
Self::apply_commit_locked(&mut state, &request, &namespace, &table, next_warehouse_location)
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
|
let result = Self::apply_commit_locked(&mut state, &request, &namespace, &table, next_warehouse_location);
|
||||||
|
let snapshot = result.as_ref().ok().map(|_| Self::snapshot_from_state_locked(&state));
|
||||||
|
let precondition = result.as_ref().ok().map(|_| precondition);
|
||||||
|
let rollback_state = result.as_ref().ok().map(|_| rollback_state);
|
||||||
|
(result, snapshot, precondition, rollback_state)
|
||||||
|
};
|
||||||
|
let result = match (result, snapshot, precondition, rollback_state) {
|
||||||
|
(Ok(result), Some(snapshot), Some(precondition), Some(rollback_state)) => self
|
||||||
|
.finalize_snapshot_write(snapshot, precondition, rollback_state)
|
||||||
|
.await
|
||||||
|
.map(|_| result),
|
||||||
|
(result, _, _, _) => result,
|
||||||
};
|
};
|
||||||
let cas_result = result.as_ref().map(|_| ()).map_err(Clone::clone);
|
let cas_result = result.as_ref().map(|_| ()).map_err(Clone::clone);
|
||||||
record_table_commit_cas_result(&request.operation, cas_started, &cas_result);
|
record_table_commit_cas_result(&request.operation, cas_started, &cas_result);
|
||||||
@@ -2260,10 +2489,14 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn drop_table(&self, table_bucket: &str, namespace: &str, table: &str) -> TableCatalogStoreResult<()> {
|
async fn drop_table(&self, table_bucket: &str, namespace: &str, table: &str) -> TableCatalogStoreResult<()> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let table = parse_table_for_store(table)?;
|
let table = parse_table_for_store(table)?;
|
||||||
let key = Self::table_key(table_bucket, &namespace, &table);
|
let key = Self::table_key(table_bucket, &namespace, &table);
|
||||||
|
let (snapshot, precondition, rollback_state) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
if state.tables.remove(&key).is_none() {
|
if state.tables.remove(&key).is_none() {
|
||||||
return Err(TableCatalogStoreError::NotFound(format!(
|
return Err(TableCatalogStoreError::NotFound(format!(
|
||||||
"table {}/{}/{}",
|
"table {}/{}/{}",
|
||||||
@@ -2272,14 +2505,19 @@ where
|
|||||||
table.as_str()
|
table.as_str()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(())
|
(Self::snapshot_from_state_locked(&state), precondition, rollback_state)
|
||||||
|
};
|
||||||
|
self.finalize_snapshot_write(snapshot, precondition, rollback_state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_view(&self, entry: ViewEntry) -> TableCatalogStoreResult<()> {
|
async fn create_view(&self, entry: ViewEntry) -> TableCatalogStoreResult<()> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
validate_catalog_entry_version("view", entry.version)?;
|
validate_catalog_entry_version("view", entry.version)?;
|
||||||
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||||
let view = parse_table_for_store(&entry.view)?;
|
let view = parse_table_for_store(&entry.view)?;
|
||||||
let key = Self::table_key(&entry.table_bucket, &namespace, &view);
|
let key = Self::table_key(&entry.table_bucket, &namespace, &view);
|
||||||
|
let (snapshot, precondition, rollback_state) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
Self::require_table_bucket_in_state(&state, &entry.table_bucket)?;
|
Self::require_table_bucket_in_state(&state, &entry.table_bucket)?;
|
||||||
if !state
|
if !state
|
||||||
@@ -2297,11 +2535,15 @@ where
|
|||||||
entry.table_bucket, entry.namespace, entry.view
|
entry.table_bucket, entry.namespace, entry.view
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
state.views.insert(key, entry);
|
state.views.insert(key, entry);
|
||||||
Ok(())
|
(Self::snapshot_from_state_locked(&state), precondition, rollback_state)
|
||||||
|
};
|
||||||
|
self.finalize_snapshot_write(snapshot, precondition, rollback_state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_views(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Vec<ViewEntry>> {
|
async fn list_views(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Vec<ViewEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
let mut entries = state
|
let mut entries = state
|
||||||
@@ -2315,6 +2557,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_view(&self, table_bucket: &str, namespace: &str, view: &str) -> TableCatalogStoreResult<Option<ViewEntry>> {
|
async fn load_view(&self, table_bucket: &str, namespace: &str, view: &str) -> TableCatalogStoreResult<Option<ViewEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let view = parse_table_for_store(view)?;
|
let view = parse_table_for_store(view)?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
@@ -2322,6 +2565,8 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn replace_view(&self, request: ViewCommitRequest) -> TableCatalogStoreResult<ViewCommitResult> {
|
async fn replace_view(&self, request: ViewCommitRequest) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(&request.namespace)?;
|
let namespace = parse_namespace_for_store(&request.namespace)?;
|
||||||
let view = parse_table_for_store(&request.view)?;
|
let view = parse_table_for_store(&request.view)?;
|
||||||
if !is_valid_view_metadata_location(&namespace, &view, &request.new_metadata_location) {
|
if !is_valid_view_metadata_location(&namespace, &view, &request.new_metadata_location) {
|
||||||
@@ -2344,6 +2589,7 @@ where
|
|||||||
|
|
||||||
let key = Self::table_key(&request.table_bucket, &namespace, &view);
|
let key = Self::table_key(&request.table_bucket, &namespace, &view);
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
let Some(current) = state.views.get(&key).cloned() else {
|
let Some(current) = state.views.get(&key).cloned() else {
|
||||||
return Err(TableCatalogStoreError::NotFound(format!(
|
return Err(TableCatalogStoreError::NotFound(format!(
|
||||||
"view {}/{}/{}",
|
"view {}/{}/{}",
|
||||||
@@ -2369,14 +2615,21 @@ where
|
|||||||
next.version_token = format!("token-{}", Uuid::new_v4());
|
next.version_token = format!("token-{}", Uuid::new_v4());
|
||||||
next.generation = next.generation.saturating_add(1);
|
next.generation = next.generation.saturating_add(1);
|
||||||
state.views.insert(key, next.clone());
|
state.views.insert(key, next.clone());
|
||||||
|
let snapshot = Self::snapshot_from_state_locked(&state);
|
||||||
|
drop(state);
|
||||||
|
self.finalize_snapshot_write(snapshot, precondition, rollback_state).await?;
|
||||||
Ok(ViewCommitResult { view: next })
|
Ok(ViewCommitResult { view: next })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drop_view(&self, table_bucket: &str, namespace: &str, view: &str) -> TableCatalogStoreResult<()> {
|
async fn drop_view(&self, table_bucket: &str, namespace: &str, view: &str) -> TableCatalogStoreResult<()> {
|
||||||
|
let _write_guard = self.write_lock.lock().await;
|
||||||
|
self.hydrate_state().await?;
|
||||||
let namespace = parse_namespace_for_store(namespace)?;
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
let view = parse_table_for_store(view)?;
|
let view = parse_table_for_store(view)?;
|
||||||
let key = Self::table_key(table_bucket, &namespace, &view);
|
let key = Self::table_key(table_bucket, &namespace, &view);
|
||||||
|
let (snapshot, precondition, rollback_state) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
|
let (precondition, rollback_state) = Self::snapshot_write_context_locked(&state);
|
||||||
if state.views.remove(&key).is_none() {
|
if state.views.remove(&key).is_none() {
|
||||||
return Err(TableCatalogStoreError::NotFound(format!(
|
return Err(TableCatalogStoreError::NotFound(format!(
|
||||||
"view {}/{}/{}",
|
"view {}/{}/{}",
|
||||||
@@ -2385,7 +2638,9 @@ where
|
|||||||
view.as_str()
|
view.as_str()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(())
|
(Self::snapshot_from_state_locked(&state), precondition, rollback_state)
|
||||||
|
};
|
||||||
|
self.finalize_snapshot_write(snapshot, precondition, rollback_state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_commit_by_id(
|
async fn get_commit_by_id(
|
||||||
@@ -2394,6 +2649,7 @@ where
|
|||||||
table_id: &str,
|
table_id: &str,
|
||||||
commit_id: &str,
|
commit_id: &str,
|
||||||
) -> TableCatalogStoreResult<Option<CommitLogEntry>> {
|
) -> TableCatalogStoreResult<Option<CommitLogEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
Ok(state
|
Ok(state
|
||||||
.commits
|
.commits
|
||||||
@@ -2407,6 +2663,7 @@ where
|
|||||||
table_id: &str,
|
table_id: &str,
|
||||||
idempotency_key: &str,
|
idempotency_key: &str,
|
||||||
) -> TableCatalogStoreResult<Option<CommitLogEntry>> {
|
) -> TableCatalogStoreResult<Option<CommitLogEntry>> {
|
||||||
|
self.hydrate_state().await?;
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
Ok(state
|
Ok(state
|
||||||
.idempotency
|
.idempotency
|
||||||
@@ -8805,6 +9062,8 @@ mod tests {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct TestCatalogObjectState {
|
struct TestCatalogObjectState {
|
||||||
objects: BTreeMap<(String, String), TestCatalogObjectRecord>,
|
objects: BTreeMap<(String, String), TestCatalogObjectRecord>,
|
||||||
|
fail_read_attempts: BTreeMap<(String, String), BTreeSet<usize>>,
|
||||||
|
read_attempts: BTreeMap<(String, String), usize>,
|
||||||
fail_put_attempts: BTreeMap<(String, String), BTreeSet<usize>>,
|
fail_put_attempts: BTreeMap<(String, String), BTreeSet<usize>>,
|
||||||
fail_delete_attempts: BTreeMap<(String, String), BTreeSet<usize>>,
|
fail_delete_attempts: BTreeMap<(String, String), BTreeSet<usize>>,
|
||||||
put_attempts: BTreeMap<(String, String), usize>,
|
put_attempts: BTreeMap<(String, String), usize>,
|
||||||
@@ -8866,6 +9125,20 @@ mod tests {
|
|||||||
state.read_calls = 0;
|
state.read_calls = 0;
|
||||||
state.list_calls = 0;
|
state.list_calls = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fail_next_read(&self, bucket: &str, object: &str) {
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
let key = (bucket.to_string(), object.to_string());
|
||||||
|
let next_attempt = state.read_attempts.get(&key).copied().unwrap_or_default() + 1;
|
||||||
|
state.fail_read_attempts.entry(key).or_default().insert(next_attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail_next_put(&self, bucket: &str, object: &str) {
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
let key = (bucket.to_string(), object.to_string());
|
||||||
|
let next_attempt = state.put_attempts.get(&key).copied().unwrap_or_default() + 1;
|
||||||
|
state.fail_put_attempts.entry(key).or_default().insert(next_attempt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestCatalogObjectState {
|
impl TestCatalogObjectState {
|
||||||
@@ -9121,10 +9394,22 @@ mod tests {
|
|||||||
async fn read_object(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Option<TableCatalogObject>> {
|
async fn read_object(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Option<TableCatalogObject>> {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
state.read_calls += 1;
|
state.read_calls += 1;
|
||||||
Ok(state
|
let key = (bucket.to_string(), object.to_string());
|
||||||
.objects
|
let attempt = {
|
||||||
.get(&(bucket.to_string(), object.to_string()))
|
let attempts = state.read_attempts.entry(key.clone()).or_default();
|
||||||
.map(|record| TableCatalogObject {
|
*attempts += 1;
|
||||||
|
*attempts
|
||||||
|
};
|
||||||
|
if state
|
||||||
|
.fail_read_attempts
|
||||||
|
.get(&key)
|
||||||
|
.is_some_and(|attempts| attempts.contains(&attempt))
|
||||||
|
{
|
||||||
|
return Err(TableCatalogStoreError::Internal(format!(
|
||||||
|
"injected read failure for {object} attempt {attempt}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(state.objects.get(&key).map(|record| TableCatalogObject {
|
||||||
data: record.data.clone(),
|
data: record.data.clone(),
|
||||||
etag: Some(record.etag.clone()),
|
etag: Some(record.etag.clone()),
|
||||||
mod_time: record.mod_time,
|
mod_time: record.mod_time,
|
||||||
@@ -12699,6 +12984,271 @@ mod tests {
|
|||||||
assert_eq!(recovery.manual_review_count, 0);
|
assert_eq!(recovery.manual_review_count, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn strong_catalog_backing_replays_durable_commit_state_after_restart() {
|
||||||
|
let backend = TestCatalogObjectBackend::default();
|
||||||
|
let store = StrongTableCatalogStore::new(backend.clone());
|
||||||
|
let bucket = "analytics";
|
||||||
|
let namespace = Namespace::parse("sales").expect("namespace should parse");
|
||||||
|
let table = IdentifierSegment::parse("orders").expect("table should parse");
|
||||||
|
let current_metadata = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
||||||
|
let new_metadata = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
|
||||||
|
|
||||||
|
store
|
||||||
|
.put_table_bucket(test_bucket_entry(bucket))
|
||||||
|
.await
|
||||||
|
.expect("table bucket should be created");
|
||||||
|
store
|
||||||
|
.create_namespace(test_namespace_entry(bucket, &namespace))
|
||||||
|
.await
|
||||||
|
.expect("namespace should be created");
|
||||||
|
store
|
||||||
|
.create_table(test_table_entry(bucket, &namespace, &table, current_metadata.clone()))
|
||||||
|
.await
|
||||||
|
.expect("table should be created");
|
||||||
|
backend.seed_object(bucket, &new_metadata, b"{}".to_vec()).await;
|
||||||
|
|
||||||
|
let result = store
|
||||||
|
.commit_table(TableCommitRequest {
|
||||||
|
table_bucket: bucket.to_string(),
|
||||||
|
namespace: namespace.public_name(),
|
||||||
|
table: table.as_str().to_string(),
|
||||||
|
commit_id: "commit-1".to_string(),
|
||||||
|
idempotency_key: Some("client-request".to_string()),
|
||||||
|
operation: "append".to_string(),
|
||||||
|
expected_version_token: "token-v1".to_string(),
|
||||||
|
expected_metadata_location: current_metadata,
|
||||||
|
new_metadata_location: new_metadata.clone(),
|
||||||
|
requirements: Vec::new(),
|
||||||
|
writer: Some("pyiceberg/test".to_string()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("commit should succeed");
|
||||||
|
|
||||||
|
let restarted = StrongTableCatalogStore::new(backend.clone());
|
||||||
|
let loaded = restarted
|
||||||
|
.load_table(bucket, "sales", "orders")
|
||||||
|
.await
|
||||||
|
.expect("table load should succeed")
|
||||||
|
.expect("table should replay from durable state");
|
||||||
|
assert_eq!(loaded.metadata_location, result.table.metadata_location);
|
||||||
|
assert_eq!(loaded.version_token, result.table.version_token);
|
||||||
|
assert_eq!(
|
||||||
|
restarted
|
||||||
|
.get_commit_by_id(bucket, "table-id", "commit-1")
|
||||||
|
.await
|
||||||
|
.expect("commit lookup should succeed")
|
||||||
|
.expect("commit log should replay from durable state")
|
||||||
|
.status,
|
||||||
|
CommitLogStatus::Committed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restarted
|
||||||
|
.get_commit_by_idempotency_key(bucket, "table-id", "client-request")
|
||||||
|
.await
|
||||||
|
.expect("idempotency lookup should succeed")
|
||||||
|
.expect("idempotency index should replay from durable state")
|
||||||
|
.status,
|
||||||
|
CommitLogStatus::Committed
|
||||||
|
);
|
||||||
|
|
||||||
|
let recovery = restarted
|
||||||
|
.plan_table_commit_recovery(bucket, "sales", "orders")
|
||||||
|
.await
|
||||||
|
.expect("recovery report should replay from durable state");
|
||||||
|
assert_eq!(recovery.finalized_count, 1);
|
||||||
|
assert_eq!(recovery.finalization_required_count, 0);
|
||||||
|
assert_eq!(recovery.idempotency_repair_required_count, 0);
|
||||||
|
assert_eq!(recovery.manual_review_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn strong_catalog_backing_rejects_stale_snapshot_cas_after_concurrent_restart() {
|
||||||
|
let backend = TestCatalogObjectBackend::default();
|
||||||
|
let store = StrongTableCatalogStore::new(backend.clone());
|
||||||
|
let bucket = "analytics";
|
||||||
|
let namespace = Namespace::parse("sales").expect("namespace should parse");
|
||||||
|
let table = IdentifierSegment::parse("orders").expect("table should parse");
|
||||||
|
let current_metadata = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
||||||
|
let first_metadata = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
|
||||||
|
let stale_metadata = default_table_metadata_file_path(&namespace, &table, "00003.metadata.json");
|
||||||
|
|
||||||
|
store
|
||||||
|
.put_table_bucket(test_bucket_entry(bucket))
|
||||||
|
.await
|
||||||
|
.expect("table bucket should be created");
|
||||||
|
store
|
||||||
|
.create_namespace(test_namespace_entry(bucket, &namespace))
|
||||||
|
.await
|
||||||
|
.expect("namespace should be created");
|
||||||
|
store
|
||||||
|
.create_table(test_table_entry(bucket, &namespace, &table, current_metadata.clone()))
|
||||||
|
.await
|
||||||
|
.expect("table should be created");
|
||||||
|
backend.seed_object(bucket, &first_metadata, b"{}".to_vec()).await;
|
||||||
|
backend.seed_object(bucket, &stale_metadata, b"{}".to_vec()).await;
|
||||||
|
|
||||||
|
let stale_store = StrongTableCatalogStore::new(backend.clone());
|
||||||
|
stale_store
|
||||||
|
.load_table(bucket, "sales", "orders")
|
||||||
|
.await
|
||||||
|
.expect("stale store should hydrate")
|
||||||
|
.expect("table should exist");
|
||||||
|
|
||||||
|
let first_result = store
|
||||||
|
.commit_table(TableCommitRequest {
|
||||||
|
table_bucket: bucket.to_string(),
|
||||||
|
namespace: namespace.public_name(),
|
||||||
|
table: table.as_str().to_string(),
|
||||||
|
commit_id: "commit-1".to_string(),
|
||||||
|
idempotency_key: Some("client-request-1".to_string()),
|
||||||
|
operation: "append".to_string(),
|
||||||
|
expected_version_token: "token-v1".to_string(),
|
||||||
|
expected_metadata_location: current_metadata.clone(),
|
||||||
|
new_metadata_location: first_metadata.clone(),
|
||||||
|
requirements: Vec::new(),
|
||||||
|
writer: Some("pyiceberg/test".to_string()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("first commit should succeed");
|
||||||
|
|
||||||
|
let err = stale_store
|
||||||
|
.commit_table(TableCommitRequest {
|
||||||
|
table_bucket: bucket.to_string(),
|
||||||
|
namespace: namespace.public_name(),
|
||||||
|
table: table.as_str().to_string(),
|
||||||
|
commit_id: "commit-2".to_string(),
|
||||||
|
idempotency_key: Some("client-request-2".to_string()),
|
||||||
|
operation: "append".to_string(),
|
||||||
|
expected_version_token: "token-v1".to_string(),
|
||||||
|
expected_metadata_location: current_metadata,
|
||||||
|
new_metadata_location: stale_metadata,
|
||||||
|
requirements: Vec::new(),
|
||||||
|
writer: Some("pyiceberg/test".to_string()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("stale snapshot CAS should fail");
|
||||||
|
|
||||||
|
assert_matches!(err, TableCatalogStoreError::Conflict(_));
|
||||||
|
let loaded = stale_store
|
||||||
|
.load_table(bucket, "sales", "orders")
|
||||||
|
.await
|
||||||
|
.expect("stale store should reload after CAS conflict")
|
||||||
|
.expect("table should still exist");
|
||||||
|
assert_eq!(loaded.metadata_location, first_result.table.metadata_location);
|
||||||
|
assert_eq!(loaded.version_token, first_result.table.version_token);
|
||||||
|
assert!(
|
||||||
|
stale_store
|
||||||
|
.get_commit_by_id(bucket, "table-id", "commit-2")
|
||||||
|
.await
|
||||||
|
.expect("commit lookup should succeed")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stale_store
|
||||||
|
.get_commit_by_idempotency_key(bucket, "table-id", "client-request-2")
|
||||||
|
.await
|
||||||
|
.expect("idempotency lookup should succeed")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn strong_catalog_backing_refreshes_hydrated_reads_after_independent_commit() {
|
||||||
|
let backend = TestCatalogObjectBackend::default();
|
||||||
|
let writer = StrongTableCatalogStore::new(backend.clone());
|
||||||
|
let reader = StrongTableCatalogStore::new(backend.clone());
|
||||||
|
let bucket = "analytics";
|
||||||
|
let namespace = Namespace::parse("sales").expect("namespace should parse");
|
||||||
|
let table = IdentifierSegment::parse("orders").expect("table should parse");
|
||||||
|
let current_metadata = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
||||||
|
let new_metadata = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
|
||||||
|
|
||||||
|
writer
|
||||||
|
.put_table_bucket(test_bucket_entry(bucket))
|
||||||
|
.await
|
||||||
|
.expect("table bucket should be created");
|
||||||
|
writer
|
||||||
|
.create_namespace(test_namespace_entry(bucket, &namespace))
|
||||||
|
.await
|
||||||
|
.expect("namespace should be created");
|
||||||
|
writer
|
||||||
|
.create_table(test_table_entry(bucket, &namespace, &table, current_metadata.clone()))
|
||||||
|
.await
|
||||||
|
.expect("table should be created");
|
||||||
|
backend.seed_object(bucket, &new_metadata, b"{}".to_vec()).await;
|
||||||
|
|
||||||
|
let loaded_before_commit = reader
|
||||||
|
.load_table(bucket, "sales", "orders")
|
||||||
|
.await
|
||||||
|
.expect("reader should hydrate")
|
||||||
|
.expect("table should exist");
|
||||||
|
assert_eq!(loaded_before_commit.metadata_location, current_metadata);
|
||||||
|
|
||||||
|
let result = writer
|
||||||
|
.commit_table(TableCommitRequest {
|
||||||
|
table_bucket: bucket.to_string(),
|
||||||
|
namespace: namespace.public_name(),
|
||||||
|
table: table.as_str().to_string(),
|
||||||
|
commit_id: "commit-1".to_string(),
|
||||||
|
idempotency_key: Some("client-request-1".to_string()),
|
||||||
|
operation: "append".to_string(),
|
||||||
|
expected_version_token: "token-v1".to_string(),
|
||||||
|
expected_metadata_location: current_metadata,
|
||||||
|
new_metadata_location: new_metadata,
|
||||||
|
requirements: Vec::new(),
|
||||||
|
writer: Some("pyiceberg/test".to_string()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("writer commit should succeed");
|
||||||
|
|
||||||
|
let loaded_after_commit = reader
|
||||||
|
.load_table(bucket, "sales", "orders")
|
||||||
|
.await
|
||||||
|
.expect("reader should refresh durable state")
|
||||||
|
.expect("table should still exist");
|
||||||
|
assert_eq!(loaded_after_commit.metadata_location, result.table.metadata_location);
|
||||||
|
assert_eq!(loaded_after_commit.version_token, result.table.version_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn strong_catalog_backing_rolls_back_cache_when_persist_and_reload_fail() {
|
||||||
|
let backend = TestCatalogObjectBackend::default();
|
||||||
|
let store = StrongTableCatalogStore::new(backend.clone());
|
||||||
|
let bucket = "analytics";
|
||||||
|
let namespace = Namespace::parse("sales").expect("namespace should parse");
|
||||||
|
let snapshot_path = StrongTableCatalogStore::<TestCatalogObjectBackend>::snapshot_object_path();
|
||||||
|
|
||||||
|
store
|
||||||
|
.put_table_bucket(test_bucket_entry(bucket))
|
||||||
|
.await
|
||||||
|
.expect("table bucket should be created");
|
||||||
|
backend.fail_next_put(RUSTFS_META_BUCKET, &snapshot_path).await;
|
||||||
|
backend.fail_next_read(RUSTFS_META_BUCKET, &snapshot_path).await;
|
||||||
|
|
||||||
|
let err = store
|
||||||
|
.create_namespace(test_namespace_entry(bucket, &namespace))
|
||||||
|
.await
|
||||||
|
.expect_err("namespace write should fail");
|
||||||
|
|
||||||
|
assert_matches!(err, TableCatalogStoreError::Internal(_));
|
||||||
|
let state = store.state.lock().await;
|
||||||
|
assert!(
|
||||||
|
!state
|
||||||
|
.namespaces
|
||||||
|
.contains_key(&StrongTableCatalogStore::<TestCatalogObjectBackend>::namespace_key(bucket, &namespace))
|
||||||
|
);
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_namespaces(bucket)
|
||||||
|
.await
|
||||||
|
.expect("durable state should reload after transient failure")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn strong_catalog_backing_commit_conflict_keeps_pointer_and_wal_unchanged() {
|
async fn strong_catalog_backing_commit_conflict_keeps_pointer_and_wal_unchanged() {
|
||||||
let backend = TestCatalogObjectBackend::default();
|
let backend = TestCatalogObjectBackend::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user