laskoviymishka commented on code in PR #3309:
URL: https://github.com/apache/iceberg-rust/pull/3309#discussion_r4153168760


##########
crates/catalog/sql/src/catalog.rs:
##########
@@ -1168,6 +1208,52 @@ impl Catalog for SqlCatalog {
         Ok(builder.build()?)
     }
 
+    async fn unregister_table(&self, table_ident: &TableIdent) -> 
Result<Table> {
+        let rows = self
+            .fetch_rows(
+                &format!(
+                    "SELECT {CATALOG_FIELD_METADATA_LOCATION_PROP}
+                     FROM {CATALOG_TABLE_NAME}
+                     WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
+                      AND {CATALOG_FIELD_TABLE_NAME} = ?
+                      AND {CATALOG_FIELD_TABLE_NAMESPACE} = ?
+                      {}",
+                    self.schema_version.record_type_filter()
+                ),
+                vec![
+                    Some(&self.name),
+                    Some(table_ident.name()),
+                    Some(&table_ident.namespace().join(".")),
+                ],
+            )
+            .await?;
+        let row = rows.first().ok_or_else(|| {
+            Error::new(
+                ErrorKind::TableNotFound,
+                format!("No such table: {table_ident}"),
+            )
+        })?;
+        let metadata_location = row
+            .try_get::<String, _>(CATALOG_FIELD_METADATA_LOCATION_PROP)
+            .map_err(from_sqlx_error)?;
+
+        self.remove_table_at_location(table_ident, &metadata_location)

Review Comment:
   This removes the catalog entry before reading the metadata that populates 
the return value, so a transient read failure here leaves the table permanently 
unregistered with no recovery — the caller gets an error and never sees the 
`metadata_location` it would need to re-register. That's the opposite of the 
safety unregister is meant to buy over `drop_table`.
   
   I'd read and validate the metadata first, then call 
`remove_table_at_location`. `register_table` already reads-before-inserts for 
exactly this reason, and the 0-rows conflict path stays intact:
   
   ```rust
   let metadata = TableMetadata::read_from(&self.fileio, 
&metadata_location).await?;
   self.remove_table_at_location(table_ident, &metadata_location).await?;
   ```



##########
crates/iceberg/src/catalog/mod.rs:
##########
@@ -121,6 +121,12 @@ pub trait Catalog: Debug + Sync + Send {
     /// Register an existing table to the catalog.
     async fn register_table(&self, table: &TableIdent, metadata_location: 
String) -> Result<Table>;
 
+    /// Unregister a table without deleting its data or metadata files.
+    ///
+    /// The returned table contains the last committed metadata and its 
location,
+    /// which can be passed to [`Catalog::register_table`] to register it 
again.
+    async fn unregister_table(&self, table: &TableIdent) -> Result<Table>;

Review Comment:
   Adding this as a required method with no default is a breaking change for 
every downstream crate implementing `Catalog` (and `SessionCatalog` in 
session.rs) — they all stop compiling. Since three of the six in-tree backends 
only return `FeatureUnsupported`, a default `Err(FeatureUnsupported, 
"unregister_table is not supported by this catalog")` is the natural baseline: 
the glue/hms/s3tables stubs collapse into it, and external impls keep building.



##########
crates/iceberg/src/catalog/memory/catalog.rs:
##########
@@ -399,6 +399,26 @@ impl Catalog for MemoryCatalog {
         builder.build()
     }
 
+    async fn unregister_table(&self, table_ident: &TableIdent) -> 
Result<Table> {
+        let metadata_location = {
+            let mut root_namespace_state = 
self.root_namespace_state.lock().await;
+            root_namespace_state.remove_existing_table(table_ident)?

Review Comment:
   Same inversion as the SQL path: `remove_existing_table` drops the entry and 
releases the lock before `read_from` runs, so a failed read leaves the table 
irrecoverably unregistered. I'd get the metadata_location under the lock, read 
the metadata while still holding it (as `load_table_from_locked_state` does), 
then remove — the entry only disappears once the file is confirmed readable.



##########
crates/catalog/sql/src/catalog.rs:
##########
@@ -1168,6 +1208,52 @@ impl Catalog for SqlCatalog {
         Ok(builder.build()?)
     }
 
+    async fn unregister_table(&self, table_ident: &TableIdent) -> 
Result<Table> {
+        let rows = self
+            .fetch_rows(
+                &format!(
+                    "SELECT {CATALOG_FIELD_METADATA_LOCATION_PROP}
+                     FROM {CATALOG_TABLE_NAME}
+                     WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
+                      AND {CATALOG_FIELD_TABLE_NAME} = ?
+                      AND {CATALOG_FIELD_TABLE_NAMESPACE} = ?
+                      {}",
+                    self.schema_version.record_type_filter()
+                ),
+                vec![
+                    Some(&self.name),
+                    Some(table_ident.name()),
+                    Some(&table_ident.namespace().join(".")),
+                ],
+            )
+            .await?;
+        let row = rows.first().ok_or_else(|| {
+            Error::new(
+                ErrorKind::TableNotFound,
+                format!("No such table: {table_ident}"),

Review Comment:
   Every other `TableNotFound` in this file goes through 
`no_such_table_err(table_ident)`, which formats with Debug — this one uses 
Display, so we end up with two different message strings for the same semantic 
error. Use the helper (it's already imported).



##########
crates/iceberg/src/catalog/memory/catalog.rs:
##########
@@ -1944,6 +1964,35 @@ pub(crate) mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_unregister_table() {
+        let catalog = new_memory_catalog().await;
+        let namespace = NamespaceIdent::new("unregister_namespace".into());
+        create_namespace(&catalog, &namespace).await;
+        let table_ident = TableIdent::new(namespace, 
"unregister_table".into());
+        create_table(&catalog, &table_ident).await;
+
+        let before = catalog.load_table(&table_ident).await.unwrap();
+        let unregistered = 
catalog.unregister_table(&table_ident).await.unwrap();
+        assert_eq!(unregistered.identifier(), &table_ident);
+        assert_eq!(unregistered.metadata_location(), 
before.metadata_location());
+        assert_eq!(unregistered.metadata(), before.metadata());
+        assert!(unregistered.readonly());
+        assert!(!catalog.table_exists(&table_ident).await.unwrap());
+
+        let err = catalog.unregister_table(&table_ident).await.unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::TableNotFound);
+
+        catalog
+            .register_table(
+                &table_ident,
+                unregistered.metadata_location().unwrap().to_string(),
+            )
+            .await
+            .unwrap();
+        assert!(catalog.table_exists(&table_ident).await.unwrap());

Review Comment:
   The re-registration here proves the catalog entry can come back, but not 
that the metadata file was left untouched — which is the actual contract of 
unregister. I'd add a `file_io.exists(metadata_location)` assertion after the 
unregister (or a `drop_table` + read-back), so a future refactor that 
accidentally wires up file deletion gets caught.



##########
crates/catalog/sql/src/catalog.rs:
##########
@@ -1168,6 +1208,52 @@ impl Catalog for SqlCatalog {
         Ok(builder.build()?)
     }
 
+    async fn unregister_table(&self, table_ident: &TableIdent) -> 
Result<Table> {

Review Comment:
   `register_table` guards non-V1 schemas with an early `FeatureUnsupported`; 
this path has no equivalent. If `record_type_filter()` behaves differently on 
V2, the SELECT can miss an existing row and return `TableNotFound` for a table 
that's actually there. I'd add the same guard, or add a V2 test that proves the 
round-trip.



##########
crates/catalog/glue/src/catalog.rs:
##########
@@ -897,6 +897,13 @@ impl Catalog for GlueCatalog {
         Ok(builder.build()?)
     }
 
+    async fn unregister_table(&self, _table_ident: &TableIdent) -> 
Result<Table> {

Review Comment:
   Glue can do this natively — `delete_table` drops the catalog pointer without 
touching files, so unregister here is `load_table` + `delete_table` returning 
the read-only table, the same shape as the SQL path. Worth implementing rather 
than stubbing. For hms/s3tables the stub is fine, but I'd drop "yet" from the 
message so we're not implying a roadmap commitment we haven't made.



##########
crates/catalog/rest/src/types.rs:
##########
@@ -231,6 +231,16 @@ pub struct LoadTableResult {
     pub storage_credentials: Option<Vec<StorageCredential>>,
 }
 
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+/// Result returned when a table is successfully unregistered from a catalog.
+pub struct UnregisterTableResult {

Review Comment:
   Callers of `unregister_table` only ever get a `Table` back — they never 
construct or inspect `UnregisterTableResult`. I'd make it `pub(crate)` so we're 
not permanently committing an internal HTTP-parsing DTO to the stable API. 
(`CommitTableResponse` being public is the existing precedent, but I'm not 
convinced that one's right either.)



##########
crates/catalog/sql/src/catalog.rs:
##########
@@ -1465,6 +1551,78 @@ mod tests {
         new_sql_catalog(warehouse_loc.clone(), Some("iceberg")).await;
     }
 
+    #[tokio::test]
+    async fn test_unregister_table() {
+        let catalog = new_sql_catalog(temp_path(), Some("iceberg")).await;
+        let namespace = NamespaceIdent::new("unregister_namespace".into());
+        create_namespace(&catalog, &namespace).await;
+        let table_ident = TableIdent::new(namespace, 
"unregister_table".into());
+        create_table(&catalog, &table_ident).await;
+
+        let before = catalog.load_table(&table_ident).await.unwrap();
+        let unregistered = 
catalog.unregister_table(&table_ident).await.unwrap();
+        assert_eq!(unregistered.identifier(), &table_ident);
+        assert_eq!(unregistered.metadata_location(), 
before.metadata_location());
+        assert_eq!(unregistered.metadata(), before.metadata());
+        assert!(unregistered.readonly());
+        assert!(!catalog.table_exists(&table_ident).await.unwrap());
+
+        let err = catalog.unregister_table(&table_ident).await.unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::TableNotFound);
+    }
+
+    #[tokio::test]
+    async fn test_unregister_table_rejects_changed_metadata_location() {
+        let db_path = temp_path();
+        let uri = format!("sqlite:{db_path}");
+        sqlx::Sqlite::create_database(&uri).await.unwrap();
+        let catalog = SqlCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "iceberg",
+                HashMap::from([
+                    (SQL_CATALOG_PROP_URI.to_string(), uri),
+                    (SQL_CATALOG_PROP_WAREHOUSE.to_string(), temp_path()),
+                ]),
+            )
+            .await
+            .unwrap();
+        let namespace = NamespaceIdent::new("unregister_conflict".into());
+        create_namespace(&catalog, &namespace).await;
+        let table_ident = TableIdent::new(namespace, "table".into());
+        create_table(&catalog, &table_ident).await;
+
+        let stale_location = catalog
+            .load_table(&table_ident)
+            .await
+            .unwrap()
+            .metadata_location()
+            .unwrap()
+            .to_string();
+        catalog
+            .execute(
+                "UPDATE iceberg_tables SET metadata_location = ?
+                 WHERE catalog_name = ? AND table_namespace = ? AND table_name 
= ?",
+                vec![
+                    Some("new-location"),
+                    Some("iceberg"),
+                    Some("unregister_conflict"),
+                    Some("table"),
+                ],
+                None,
+            )
+            .await
+            .unwrap();
+
+        let err = catalog
+            .remove_table_at_location(&table_ident, &stale_location)

Review Comment:
   This exercises the conflict path by calling the private 
`remove_table_at_location` directly, so the real end-to-end race (SELECT → 
concurrent update → CAS DELETE) never runs through `unregister_table`. Once the 
read-before-remove swap lands, I'd point this at the public `unregister_table` 
so it covers the actual sequence a caller hits.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to