yuqi1129 commented on code in PR #13058:
URL: https://github.com/apache/gravitino/pull/13058#discussion_r3978554341


##########
catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java:
##########
@@ -233,7 +250,16 @@ public Table createTable(
       Index[] indexes)
       throws NoSuchSchemaException, TableAlreadyExistsException {
     Schema schema = loadSchema(NameIdentifier.of(ident.namespace().levels()));
-    String tableLocation = calculateTableLocation(schema, ident, properties);
+    String tableLocation =
+        validateProvisionedLocation(
+            tableLocationProvider.provisionTableLocation(

Review Comment:
   **Blocking (correctness): external and registered tables.** This calls the 
provider unconditionally, and the returned value always wins -- 
`newProperties.put(Table.PROPERTY_LOCATION, tableLocation)` a few lines down 
overwrites whatever the user passed.
   
   For an external table the user-supplied `location` is not a hint, it is the 
whole point: `GenericTablePropertiesMetadata.java:43` documents it as required 
for external tables, and `LanceTableOperations.createTable` supports 
registering an existing dataset. With a custom allocator provider selected, 
registering an existing dataset would silently point the table somewhere else. 
Please skip provisioning when `Table.PROPERTY_EXTERNAL` is true (and expose 
`isExternal()` on `TableLocationContext` so a provider can see it too).
   
   **Blocking (correctness): a failed creation leaks the allocation.** 
Provisioning happens *before* the format is validated (`format != null`), 
before `tableOpsCache.get(format)` is looked up, and before 
`tableOps.createTable(...)` runs. A missing format, an unsupported format, a 
`TableAlreadyExistsException` or any failure inside the delegator all leave a 
location allocated from the external service with nothing left to reclaim it -- 
`unprovisionTableLocation` never fires, because the table does not exist to be 
dropped.
   
   Unlike the drop path, this one is cheap to get right and the asymmetry 
argument in the PR description does not apply: the table demonstrably was not 
created, so reclaiming is safe. Suggest moving the format validation and the 
`tableOpsCache` lookup above the provisioning, and wrapping 
`tableOps.createTable(...)` in a try/catch that unprovisions (swallowing any 
secondary failure with a WARN) before rethrowing.



##########
catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java:
##########
@@ -273,57 +299,80 @@ public Table alterTable(NameIdentifier ident, 
TableChange... changes)
 
   @Override
   public boolean purgeTable(NameIdentifier ident) {
-    boolean purged = tableOps(ident).purgeTable(ident);
-    tableFormatCache.invalidate(ident);
-    return purged;
+    return dropOrPurgeTable(ident, true /* purge */);
   }
 
   @Override
   public boolean dropTable(NameIdentifier ident) throws 
UnsupportedOperationException {
-    boolean dropped = tableOps(ident).dropTable(ident);
-    tableFormatCache.invalidate(ident);
-    return dropped;
+    return dropOrPurgeTable(ident, false /* purge */);
   }
 
-  private String calculateTableLocation(
-      Schema schema, NameIdentifier tableIdent, Map<String, String> 
tableProperties) {
-    String tableLocation =
-        (String)
-            propertiesMetadata
-                .tablePropertiesMetadata()
-                .getOrDefault(tableProperties, Table.PROPERTY_LOCATION);
-    if (StringUtils.isNotBlank(tableLocation)) {
-      return ensureTrailingSlash(tableLocation);
+  /**
+   * Drops or purges a table, and hands its location back to the {@link 
TableLocationProvider}
+   * afterwards.
+   *
+   * <p>The table properties are read before the removal, because they carry 
the location the
+   * provider has to hand back, and the unprovisioning itself happens after 
the removal so that a
+   * provider never reclaims the storage of a table that is still there. A 
provider failing to
+   * unprovision is logged at WARN rather than propagated: the table is 
already gone at that point,
+   * so failing the request would report a drop that did in fact happen as 
unsuccessful and invite a
+   * retry that cannot undo anything.
+   *
+   * @param ident the identifier of the table to drop
+   * @param purge whether to purge the table instead of dropping it
+   * @return true if the table was dropped, false if it did not exist
+   */
+  private boolean dropOrPurgeTable(NameIdentifier ident, boolean purge) {
+    Map<String, String> tableProperties;
+    try {
+      tableProperties = store.get(ident, TABLE, 
TableEntity.class).properties();
+    } catch (NoSuchEntityException e) {
+      return false;
+    } catch (IOException e) {
+      throw new RuntimeException(
+          String.format("Failed to load table %s before dropping it", ident), 
e);
     }
 
-    String schemaLocation =
-        schema.properties() == null ? null : 
schema.properties().get(Schema.PROPERTY_LOCATION);
-
-    // If we do not set location in table properties, and schema location is 
set, use schema
-    // location as the base path.
-    if (StringUtils.isNotBlank(schemaLocation)) {
-      return ensureTrailingSlash(schemaLocation) + tableIdent.name() + SLASH;
-    }
+    // Built entirely before the drop, so that a store read failing here fails 
the request while
+    // the table is still there, rather than after it is gone where it could 
only be reported as a
+    // provider failure it is not.
+    TableLocationContext context =
+        TableLocationContext.builder()
+            .withTableIdentifier(ident)
+            .withTableProperties(tableProperties)
+            
.withSchema(loadSchema(NameIdentifier.of(ident.namespace().levels())))
+            .build();
+
+    boolean dropped = purge ? tableOps(ident).purgeTable(ident) : 
tableOps(ident).dropTable(ident);
+    tableFormatCache.invalidate(ident);
 
-    // If the schema location is not set, use catalog lakehouse dir as the 
base path. Or else, throw
-    // an exception.
-    if (catalogLocation.isEmpty()) {
-      throw new IllegalArgumentException(
-          "'location' property is neither set in table properties "
-              + "nor in schema properties, and no location is set in catalog 
properties either. "
-              + "Please set the 'location' in either of them to create the 
table "
-              + tableIdent);
+    if (dropped) {
+      try {
+        tableLocationProvider.unprovisionTableLocation(context);

Review Comment:
   **Blocking (correctness): this fires for external tables too.**
   
   `LanceTableOperations.dropTable` (lines 354-379) and `purgeTable` (317-347) 
both read `Table.PROPERTY_EXTERNAL` and deliberately do *not* delete the 
dataset when it is set -- the storage belongs to the user, Gravitino only 
registered it. This call has no such guard, so a provider that genuinely 
reclaims storage will release a location Gravitino never allocated.
   
   By the PR's own standard ("Leaked storage is recoverable; a premature 
reclaim is data loss") this is the failure mode worth defending against. 
Suggest mirroring the Lance check and skipping the unprovision when the dropped 
table was external.
   
   Everything else about this block looks right to me: reading the properties 
before the removal, unprovisioning after it, and downgrading a provider failure 
to a WARN are all the correct calls, and the reasoning in the javadoc is sound.



##########
catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java:
##########
@@ -273,57 +299,80 @@ public Table alterTable(NameIdentifier ident, 
TableChange... changes)
 
   @Override
   public boolean purgeTable(NameIdentifier ident) {
-    boolean purged = tableOps(ident).purgeTable(ident);
-    tableFormatCache.invalidate(ident);
-    return purged;
+    return dropOrPurgeTable(ident, true /* purge */);
   }
 
   @Override
   public boolean dropTable(NameIdentifier ident) throws 
UnsupportedOperationException {
-    boolean dropped = tableOps(ident).dropTable(ident);
-    tableFormatCache.invalidate(ident);
-    return dropped;
+    return dropOrPurgeTable(ident, false /* purge */);
   }
 
-  private String calculateTableLocation(
-      Schema schema, NameIdentifier tableIdent, Map<String, String> 
tableProperties) {
-    String tableLocation =
-        (String)
-            propertiesMetadata
-                .tablePropertiesMetadata()
-                .getOrDefault(tableProperties, Table.PROPERTY_LOCATION);
-    if (StringUtils.isNotBlank(tableLocation)) {
-      return ensureTrailingSlash(tableLocation);
+  /**
+   * Drops or purges a table, and hands its location back to the {@link 
TableLocationProvider}
+   * afterwards.
+   *
+   * <p>The table properties are read before the removal, because they carry 
the location the
+   * provider has to hand back, and the unprovisioning itself happens after 
the removal so that a
+   * provider never reclaims the storage of a table that is still there. A 
provider failing to
+   * unprovision is logged at WARN rather than propagated: the table is 
already gone at that point,
+   * so failing the request would report a drop that did in fact happen as 
unsuccessful and invite a
+   * retry that cannot undo anything.
+   *
+   * @param ident the identifier of the table to drop
+   * @param purge whether to purge the table instead of dropping it
+   * @return true if the table was dropped, false if it did not exist
+   */
+  private boolean dropOrPurgeTable(NameIdentifier ident, boolean purge) {
+    Map<String, String> tableProperties;
+    try {
+      tableProperties = store.get(ident, TABLE, 
TableEntity.class).properties();
+    } catch (NoSuchEntityException e) {

Review Comment:
   **Undeclared behaviour change.** Before this PR, 
`dropTable(nonExistentTable)` reached `tableOps(ident)`, whose `store.get` 
threw `NoSuchEntityException`, which the catch block at the bottom of 
`tableOps` converted into `NoSuchTableException`. Now it returns `false` here.
   
   I actually prefer the new behaviour -- returning `false` is what 
`TableCatalog#dropTable` documents -- but it is visible through REST (an error 
response becomes `dropped: false`), so "No behavioural change for existing 
catalogs" in the PR description is not accurate. No existing unit test or IT 
covers it, so CI will not flag it either. Could you state it in the description 
and add a test pinning the chosen semantics?



##########
catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java:
##########
@@ -273,57 +299,80 @@ public Table alterTable(NameIdentifier ident, 
TableChange... changes)
 
   @Override
   public boolean purgeTable(NameIdentifier ident) {
-    boolean purged = tableOps(ident).purgeTable(ident);
-    tableFormatCache.invalidate(ident);
-    return purged;
+    return dropOrPurgeTable(ident, true /* purge */);
   }
 
   @Override
   public boolean dropTable(NameIdentifier ident) throws 
UnsupportedOperationException {
-    boolean dropped = tableOps(ident).dropTable(ident);
-    tableFormatCache.invalidate(ident);
-    return dropped;
+    return dropOrPurgeTable(ident, false /* purge */);
   }
 
-  private String calculateTableLocation(
-      Schema schema, NameIdentifier tableIdent, Map<String, String> 
tableProperties) {
-    String tableLocation =
-        (String)
-            propertiesMetadata
-                .tablePropertiesMetadata()
-                .getOrDefault(tableProperties, Table.PROPERTY_LOCATION);
-    if (StringUtils.isNotBlank(tableLocation)) {
-      return ensureTrailingSlash(tableLocation);
+  /**
+   * Drops or purges a table, and hands its location back to the {@link 
TableLocationProvider}
+   * afterwards.
+   *
+   * <p>The table properties are read before the removal, because they carry 
the location the
+   * provider has to hand back, and the unprovisioning itself happens after 
the removal so that a
+   * provider never reclaims the storage of a table that is still there. A 
provider failing to
+   * unprovision is logged at WARN rather than propagated: the table is 
already gone at that point,
+   * so failing the request would report a drop that did in fact happen as 
unsuccessful and invite a
+   * retry that cannot undo anything.
+   *
+   * @param ident the identifier of the table to drop
+   * @param purge whether to purge the table instead of dropping it
+   * @return true if the table was dropped, false if it did not exist
+   */
+  private boolean dropOrPurgeTable(NameIdentifier ident, boolean purge) {
+    Map<String, String> tableProperties;
+    try {
+      tableProperties = store.get(ident, TABLE, 
TableEntity.class).properties();
+    } catch (NoSuchEntityException e) {
+      return false;
+    } catch (IOException e) {
+      throw new RuntimeException(
+          String.format("Failed to load table %s before dropping it", ident), 
e);
     }
 
-    String schemaLocation =
-        schema.properties() == null ? null : 
schema.properties().get(Schema.PROPERTY_LOCATION);
-
-    // If we do not set location in table properties, and schema location is 
set, use schema
-    // location as the base path.
-    if (StringUtils.isNotBlank(schemaLocation)) {
-      return ensureTrailingSlash(schemaLocation) + tableIdent.name() + SLASH;
-    }
+    // Built entirely before the drop, so that a store read failing here fails 
the request while
+    // the table is still there, rather than after it is gone where it could 
only be reported as a
+    // provider failure it is not.
+    TableLocationContext context =
+        TableLocationContext.builder()
+            .withTableIdentifier(ident)
+            .withTableProperties(tableProperties)
+            
.withSchema(loadSchema(NameIdentifier.of(ident.namespace().levels())))

Review Comment:
   **Efficiency: the same rows are read up to four times per drop.**
   
   Within a single `dropOrPurgeTable`:
   
   - line 328 reads the `TableEntity`;
   - `tableOps(ident)` on line 346 reads *the same* `TableEntity` again 
whenever `tableFormatCache` misses (it needs `PROPERTY_TABLE_FORMAT`);
   - `LanceTableOperations.dropTable` then calls `super.loadTable(ident)`, 
reading it a third time;
   - and this line adds a schema read on top.
   
   For `DefaultTableLocationProvider` none of it is needed -- 
`unprovisionTableLocation` is an empty body -- so the default deployment pays 
two extra store reads per drop for nothing. In the cascade path it is worse: 
every table reloads the *same* schema, so dropping a schema with N tables does 
N identical `loadSchema` calls.
   
   Since you deliberately (and I think correctly) kept 
`unprovisionTableLocation` abstract, a capability flag preserves that intent 
while letting the common path skip the work:
   
   ```java
   default boolean requiresUnprovision() {
     return false;
   }
   ```
   
   `DefaultTableLocationProvider` leaves it alone, a provider that reclaims 
real storage overrides it to `true`, and `dropOrPurgeTable` builds the context 
only when it is set. Hoisting the schema out of the cascade loop (see my 
comment on `dropSchema`) would take care of the rest; a `Supplier<Schema>` 
inside `TableLocationContext` would be an alternative.



##########
catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java:
##########
@@ -192,9 +207,11 @@ public boolean dropSchema(NameIdentifier ident, boolean 
cascade) throws NonEmpty
           "Schema %s is not empty, cannot drop it without cascade", ident);
     }
 
-    // Drop all tables under the schema first if cascade is true.
+    // Drop all tables under the schema first if cascade is true. This goes 
through the catalog
+    // level dropTable, so that the location of each table is unprovisioned 
and its cached format
+    // invalidated.
     for (NameIdentifier tableIdent : tableIdents) {
-      tableOps(tableIdent).dropTable(tableIdent);
+      dropTable(tableIdent);

Review Comment:
   Routing the cascade through the catalog-level `dropTable` is the right call, 
and the `tableFormatCache` invalidation it fixes was a real bug.
   
   One efficiency note: each iteration now re-enters `dropOrPurgeTable`, which 
loads the parent schema again -- N identical `loadSchema` calls for N tables, 
even though `dropSchema` could load it once. Passing the already-loaded schema 
down (or resolving it lazily in `TableLocationContext`) would avoid that.



##########
catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java:
##########
@@ -367,4 +416,31 @@ private ManagedTableOperations 
configureTableOps(ManagedTableOperations ops) {
 
     return ops;
   }
+
+  /**
+   * Validates the location returned by a {@link TableLocationProvider} before 
it is stored in the
+   * table properties, so that a misbehaving provider fails the table creation 
instead of silently
+   * producing a broken location.
+   *
+   * <p>Only blankness is checked. The shape of the path belongs to the 
provider: nothing downstream
+   * appends to the location, and the value is stored verbatim so that a 
provider unprovisioning it
+   * later sees exactly the string it returned.
+   *
+   * @param location the location returned by the provider
+   * @param providerName the name of the provider that returned it
+   * @param tableIdent the identifier of the table being created
+   * @return the validated location
+   * @throws IllegalArgumentException if the location is blank
+   */
+  @VisibleForTesting
+  static String validateProvisionedLocation(

Review Comment:
   Minor, style only:
   
   - `AGENTS.md` asks for `static` members before instance ones and `private` 
methods last; this `static` method sits between private instance methods, as 
does the `@VisibleForTesting tableFormatCache()` accessor.
   - The body is a single `Preconditions.checkArgument`. Inlining it at the 
call site in `createTable` would drop a three-parameter method plus ~20 lines 
of javadoc, and the `@VisibleForTesting` hook is not needed -- 
`TestGenericCatalogOperations` already covers the blank-location rejection 
through `createTable`.



##########
catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/TableLocationProviderFactory.java:
##########
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.catalog.lakehouse.generic;
+
+import com.google.common.base.Preconditions;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.ServiceLoader;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** A factory that discovers {@link TableLocationProvider}s through {@link 
ServiceLoader}. */
+public class TableLocationProviderFactory {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(TableLocationProviderFactory.class);
+
+  private TableLocationProviderFactory() {}
+
+  /**
+   * Creates and initializes the {@link TableLocationProvider} registered 
under the given name.
+   *
+   * <p>A new instance is returned on every call, so the caller owns its 
lifecycle and is
+   * responsible for closing it.
+   *
+   * @param name the provider name to look up, matched case-insensitively 
against {@link
+   *     TableLocationProvider#name()}
+   * @param catalogProperties the properties of the catalog the provider 
belongs to
+   * @return the initialized provider
+   * @throws IllegalArgumentException if no provider, or more than one 
provider, is registered under
+   *     the given name
+   */
+  public static TableLocationProvider create(String name, Map<String, String> 
catalogProperties) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(name), "Table location provider name must not 
be blank");
+
+    ClassLoader cl =
+        Optional.ofNullable(Thread.currentThread().getContextClassLoader())
+            .orElse(TableLocationProvider.class.getClassLoader());
+    ServiceLoader<TableLocationProvider> loader =
+        ServiceLoader.load(TableLocationProvider.class, cl);
+
+    List<TableLocationProvider> providers =
+        loader.stream()

Review Comment:
   `ServiceLoader.Provider::get` instantiates **every** provider on the 
classpath before the filter runs, and the ones that lose are neither 
initialized nor closed. The docs call the constraint out, so I am fine with the 
semantics -- but this whole scan runs on every catalog initialization.
   
   The sibling SPI in this module resolves it once and caches: 
`LakehouseTableDelegatorFactory` keeps a `private static ImmutableMap` built 
under a `synchronized` initializer. Matching that here -- cache the discovered 
`name -> Class` mapping once, then reflectively instantiate only the selected 
one per catalog -- would keep the two factories consistent, avoid instantiating 
unrelated third-party providers, and surface a duplicate `name()` at discovery 
time rather than on the unlucky catalog.



-- 
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]

Reply via email to