This is an automated email from the ASF dual-hosted git repository.

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new a9db07c53e [#14335] fix(core): Clean owner and privilege rows of 
deleted jobs and job templates, and keep columns when the catalog reports none 
(#14336)
a9db07c53e is described below

commit a9db07c53e66df31d18b2ded58d00e1edb5da6e4
Author: Qi Yu <[email protected]>
AuthorDate: Thu Oct 29 19:30:34 2026 +0800

    [#14335] fix(core): Clean owner and privilege rows of deleted jobs and job 
templates, and keep columns when the catalog reports none (#14336)
    
    ### What changes were proposed in this pull request?
    
    - `JobMetaService.deleteJob` soft-deletes the job's owner relation in
    the same transaction.
    - `JobTemplateMetaService.deleteJobTemplate` soft-deletes the template's
    owner relation and securable objects, and the owner relations of the
    jobs deleted with it (new
    `OwnerMetaMapper.softDeleteOwnerRelOfJobsByTemplateId`). Jobs can't be
    securable objects, so they have no securable-object rows to clean.
    - A table whose catalog reports no column list (`columns() == null`) is
    handled by `TableOperationDispatcher` as follows. Where it fails, it
    throws an `IllegalStateException` that names the catalog and the table:
    - `loadTable` fails before importing the table or updating its stored
    columns.
    - `createTable` fails before storing the table, where the snapshot used
    to fail with an NPE. The table stays created in the catalog, as before.
    - `alterTable` on a table stored in Gravitino completes.
    `updateColumnsIfNecessary` keeps the stored columns as they were before
    the alter, instead of treating a missing list as "all columns dropped",
    and the result reports those stored columns, with a warning logged. So
    the hook's authorization rename and the success event run as usual. A
    column drop or rename in that alter is not reflected in the stored
    columns.
    - `alterTable` on a table not stored in Gravitino, or on a managed
    table, fails after the catalog has applied the change, since there are
    no stored columns to report. The hook's authorization rename and the
    success event are then skipped, and a retried rename fails because the
    table already has its new name.
    - `DTOConverters.toDTO(Table)` keeps `null` columns instead of throwing
    an NPE. Connector results are snapshotted through it before the
    dispatcher check runs, so the check could not report its error
    otherwise. `TableDTO.columns` is marked `@Nullable` for that internal
    snapshot only. A `null` column list still never reaches the REST
    response, so the clients are unchanged.
    
    ### Why are the changes needed?
    
    - Deleting a job or job template left its owner and privilege rows live
    until the orphan relation GC collected them, so roles kept listing
    privileges on deleted templates.
    - A catalog returning `columns() == null` would have made
    `updateColumnsIfNecessary` delete all of a table's stored columns and
    their tags, policies and owners. Today an NPE in the snapshot fails the
    call first, so this is a defensive fix that also replaces the NPE with a
    clear error.
    
    Fixes #14335
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API change. Creating or loading a table whose catalog reports no
    column list fails with a clear error instead of an NPE. Altering a
    stored one completes and reports the stored columns.
    
    ### How was this patch tested?
    
    - New tests:
    - `TestJobMetaService.testDeleteJobCleansOwner` and
    `TestJobTemplateMetaService.testDeleteJobTemplateCleansOwnersAndPrivileges`.
    Both check that rows of another job or template survive.
    - `TestTableOperationDispatcher`:
    `testLoadFailsWhenCatalogReportsNoColumns`,
    `testCreateFailsWhenCatalogReportsNoColumns` (nothing is stored),
    `testAlterKeepsStoredColumnsWhenCatalogReportsNoColumns`,
    `testRenameCompletesWhenCatalogReportsNoColumns` (the stored column ids
    move with the table) and
    `testAlterUnstoredTableFailsWhenCatalogReportsNoColumns`. The alter
    tests also check that the catalog applied the change. The alter check
    for managed tables has no test.
    - `TestJobMetaService`, `TestJobTemplateMetaService`,
    `TestPolicyMetaService` and `TestOwnerMetaService` passed on H2, MySQL
    and PostgreSQL for the first commit. `TestTableOperationDispatcher`
    (49), `TestTableEvent` and `TestDTOConverters` pass.
    - Mutation checks: removing any of the following makes its test fail:
      - the template relation cleanup;
      - the owner cleanup for jobs deleted with their template;
      - the job owner cleanup;
    - the load check, the create check, the alter check for unstored tables,
    or reporting the stored columns on alter;
      - the column guard (restoring the old "all columns dropped" handling);
      - the `toDTO` null check.
---
 .../org/apache/gravitino/dto/rel/TableDTO.java     |  17 ++-
 .../apache/gravitino/dto/util/DTOConverters.java   |   8 +-
 .../gravitino/catalog/EntityCombinedTable.java     |  17 ++-
 .../catalog/TableOperationDispatcher.java          |  88 +++++++++++--
 .../storage/relational/mapper/OwnerMetaMapper.java |   5 +
 .../mapper/OwnerMetaSQLProviderFactory.java        |   5 +
 .../provider/base/OwnerMetaBaseSQLProvider.java    |  17 +++
 .../storage/relational/service/JobMetaService.java |  12 +-
 .../relational/service/JobTemplateMetaService.java |  21 ++++
 .../catalog/TestTableOperationDispatcher.java      | 137 +++++++++++++++++++++
 .../gravitino/connector/TestCatalogOperations.java |  17 ++-
 .../storage/relational/TestJDBCBackend.java        |  18 +++
 .../relational/service/TestJobMetaService.java     |  48 ++++++++
 .../service/TestJobTemplateMetaService.java        |  79 ++++++++++++
 14 files changed, 468 insertions(+), 21 deletions(-)

diff --git a/common/src/main/java/org/apache/gravitino/dto/rel/TableDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/rel/TableDTO.java
index a23a1cd37c..0140d68a3b 100644
--- a/common/src/main/java/org/apache/gravitino/dto/rel/TableDTO.java
+++ b/common/src/main/java/org/apache/gravitino/dto/rel/TableDTO.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.dto.rel;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Preconditions;
 import java.util.Map;
+import javax.annotation.Nullable;
 import lombok.EqualsAndHashCode;
 import org.apache.gravitino.dto.AuditDTO;
 import org.apache.gravitino.dto.rel.indexes.IndexDTO;
@@ -42,6 +43,8 @@ public class TableDTO implements Table {
   @JsonProperty("comment")
   private String comment;
 
+  // Null only for the internal connector snapshot; responses always carry a 
column list.
+  @Nullable
   @JsonProperty("columns")
   private ColumnDTO[] columns;
 
@@ -106,8 +109,11 @@ public class TableDTO implements Table {
   }
 
   /**
-   * @return The columns of the table.
+   * @return The columns of the table. Null only for the internal connector 
snapshot of a table
+   *     whose catalog reports no column list; such a table is rejected before 
it is returned to a
+   *     client.
    */
+  @Nullable
   @Override
   public Column[] columns() {
     return columns;
@@ -189,7 +195,7 @@ public class TableDTO implements Table {
     /** The comment associated with the table. */
     protected String comment;
     /** The columns of the table. */
-    protected ColumnDTO[] columns;
+    @Nullable protected ColumnDTO[] columns;
     /** The properties associated with the table. */
     protected Map<String, String> properties;
     /** The audit information for the table. */
@@ -231,10 +237,11 @@ public class TableDTO implements Table {
     /**
      * Sets the columns of the table.
      *
-     * @param columns The columns of the table.
+     * @param columns The columns of the table. Null is only for the internal 
connector snapshot of
+     *     a table whose catalog reports no column list; a DTO sent to clients 
always has columns.
      * @return The Builder instance.
      */
-    public S withColumns(ColumnDTO[] columns) {
+    public S withColumns(@Nullable ColumnDTO[] columns) {
       this.columns = columns;
       return (S) this;
     }
@@ -309,7 +316,7 @@ public class TableDTO implements Table {
      * Builds a Table DTO based on the provided builder parameters.
      *
      * @return A new TableDTO instance.
-     * @throws IllegalArgumentException If required fields name, columns and 
audit are not set.
+     * @throws IllegalArgumentException If required fields name and audit are 
not set.
      */
     public TableDTO build() {
       Preconditions.checkArgument(name != null && !name.isEmpty(), "name 
cannot be null or empty");
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java 
b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
index 5fdf3c51f4..406d52bee2 100644
--- a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
+++ b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
@@ -298,8 +298,14 @@ public class DTOConverters {
     return TableDTO.builder()
         .withName(table.name())
         .withComment(table.comment())
+        // Connector results are snapshotted through here before the table 
dispatcher rejects a
+        // missing column list with a clear error, so a null list must not 
throw here.
         .withColumns(
-            
Arrays.stream(table.columns()).map(DTOConverters::toDTO).toArray(ColumnDTO[]::new))
+            table.columns() == null
+                ? null
+                : Arrays.stream(table.columns())
+                    .map(DTOConverters::toDTO)
+                    .toArray(ColumnDTO[]::new))
         .withProperties(table.properties())
         .withSortOrders(toDTOs(table.sortOrder()))
         .withDistribution(toDTO(table.distribution()))
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/EntityCombinedTable.java 
b/core/src/main/java/org/apache/gravitino/catalog/EntityCombinedTable.java
index a9ee8513cb..44df60eabb 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/EntityCombinedTable.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/EntityCombinedTable.java
@@ -57,6 +57,9 @@ public final class EntityCombinedTable implements Table {
   // unique id, and based on this id, we can label and control the access to 
the entities.
   private boolean imported;
 
+  // The columns to report instead of the catalog's, set only when the catalog 
reports none.
+  private Column[] columnsOverride;
+
   private EntityCombinedTable(Table table, TableEntity tableEntity) {
     this.table = table;
     this.tableEntity = tableEntity;
@@ -78,6 +81,18 @@ public final class EntityCombinedTable implements Table {
     return this;
   }
 
+  /**
+   * Reports the given columns instead of the catalog table's. Used when the 
catalog returns a table
+   * without a column list and the stored columns stand in for it.
+   *
+   * @param columns The columns to report.
+   * @return This EntityCombinedTable.
+   */
+  EntityCombinedTable withColumns(Column[] columns) {
+    this.columnsOverride = columns;
+    return this;
+  }
+
   public EntityCombinedTable withImported(boolean imported) {
     this.imported = imported;
     return this;
@@ -99,7 +114,7 @@ public final class EntityCombinedTable implements Table {
 
   @Override
   public Column[] columns() {
-    return table.columns();
+    return columnsOverride != null ? columnsOverride : table.columns();
   }
 
   @Override
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java 
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
index 4e50f44aa4..9eb60ee9f0 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
@@ -331,9 +331,15 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
                   IllegalArgumentException.class);
           Table alteredTable = catalogResult.table;
 
+          // The change is already applied in the catalog. If the result has 
no column list and
+          // Gravitino has no stored columns to report instead, the alter 
fails, because clients
+          // require one. The hook's authorization rename and the success 
event are then skipped,
+          // and a retried rename fails because the table already has its new 
name.
           if (catalogResult.managed) {
-            return EntityCombinedTable.of(alteredTable)
-                .withHiddenProperties(catalogResult.hiddenProperties);
+            return checkColumnsReported(
+                ident,
+                EntityCombinedTable.of(alteredTable)
+                    .withHiddenProperties(catalogResult.hiddenProperties));
           }
 
           StringIdentifier stringId = 
getStringIdFromProperties(alteredTable.properties());
@@ -344,8 +350,10 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
               te = getEntity(ident, TABLE, TableEntity.class);
             }
             if (te == null) {
-              return EntityCombinedTable.of(alteredTable)
-                  .withHiddenProperties(catalogResult.hiddenProperties);
+              return checkColumnsReported(
+                  ident,
+                  EntityCombinedTable.of(alteredTable)
+                      .withHiddenProperties(catalogResult.hiddenProperties));
             }
           }
 
@@ -404,8 +412,22 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
                 ident, newIdent);
           }
 
-          return EntityCombinedTable.of(alteredTable, updatedTableEntity)
-              .withHiddenProperties(catalogResult.hiddenProperties);
+          EntityCombinedTable result =
+              EntityCombinedTable.of(alteredTable, updatedTableEntity)
+                  .withHiddenProperties(catalogResult.hiddenProperties);
+          // The store kept the columns it had before this alter (see 
updateColumnsIfNecessary).
+          // Report them, so the alter completes, including the hook's 
authorization rename,
+          // instead of failing after the catalog has already applied it. A 
column drop or rename
+          // in this alter is not reflected in them.
+          if (alteredTable.columns() == null && updatedTableEntity != null) {
+            LOG.warn(
+                "Catalog {} returned altered table {} without a column list, 
reporting the stored "
+                    + "columns",
+                getCatalogIdentifier(ident),
+                ident);
+            result.withColumns(toColumns(updatedTableEntity.columns()));
+          }
+          return checkColumnsReported(ident, result);
         });
   }
 
@@ -629,6 +651,8 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
             },
             NoSuchTableException.class);
     Table table = catalogResult.table;
+    // Fail before importing the table or updating its stored columns.
+    checkColumnsReported(ident, table);
 
     if (catalogResult.managed) {
       return EntityCombinedTable.of(table)
@@ -718,6 +742,8 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
             NoSuchSchemaException.class,
             TableAlreadyExistsException.class);
     Table table = catalogResult.table;
+    // Fail before storing the table, as a missing column list failed the 
snapshot before.
+    checkColumnsReported(ident, table);
 
     // If the table is managed by Gravitino, we don't need to create 
TableEntity and store it again.
     if (catalogResult.managed) {
@@ -769,6 +795,34 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
     return snapshotTable(catalog, table, isManagedEntity(catalog, 
Capability.Scope.TABLE));
   }
 
+  private <T extends Table> T checkColumnsReported(NameIdentifier ident, T 
table) {
+    if (table.columns() == null) {
+      throw new IllegalStateException(
+          String.format(
+              "Catalog %s returned table %s without a column list",
+              getCatalogIdentifier(ident), ident));
+    }
+    return table;
+  }
+
+  private static Column[] toColumns(@Nullable List<ColumnEntity> 
columnEntities) {
+    if (columnEntities == null) {
+      return new Column[0];
+    }
+    return columnEntities.stream()
+        .sorted(Comparator.comparing(ColumnEntity::position))
+        .map(
+            c ->
+                Column.of(
+                    c.name(),
+                    c.dataType(),
+                    c.comment(),
+                    c.nullable(),
+                    c.autoIncrement(),
+                    c.defaultValue()))
+        .toArray(Column[]::new);
+  }
+
   private List<ColumnEntity> toColumnEntities(Column[] columns, AuditInfo 
audit) {
     return columns == null
         ? Collections.emptyList()
@@ -977,12 +1031,24 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
       return Pair.of(false, Collections.emptyList());
     }
 
+    // A catalog that reports no column list says nothing about the columns, 
so keep the stored
+    // ones. Treating it as "all columns dropped" would delete their tags, 
policies and owners. Load
+    // rejects such a table before getting here, and alter keeps the columns 
as they were before it.
+    if (tableFromCatalog.columns() == null) {
+      LOG.warn(
+          "Table {} from the underlying source has no column list, keeping the 
stored columns",
+          tableFromCatalog.name());
+      return Pair.of(
+          false,
+          tableFromGravitino.columns() == null
+              ? Collections.emptyList()
+              : tableFromGravitino.columns());
+    }
+
     Map<String, Pair<Integer, Column>> columnsFromCatalogTable =
-        tableFromCatalog.columns() == null
-            ? Collections.emptyMap()
-            : IntStream.range(0, tableFromCatalog.columns().length)
-                .mapToObj(i -> Pair.of(i, tableFromCatalog.columns()[i]))
-                .collect(Collectors.toMap(p -> p.getRight().name(), 
Function.identity()));
+        IntStream.range(0, tableFromCatalog.columns().length)
+            .mapToObj(i -> Pair.of(i, tableFromCatalog.columns()[i]))
+            .collect(Collectors.toMap(p -> p.getRight().name(), 
Function.identity()));
     Map<String, ColumnEntity> columnsFromTableEntity =
         tableFromGravitino.columns() == null
             ? Collections.emptyMap()
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
index fc456f10cb..12dab8d833 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
@@ -135,6 +135,11 @@ public interface OwnerMetaMapper {
       method = "softDeleteOwnerRelBySchemaIds")
   void softDeleteOwnerRelBySchemaIds(@Param("schemaIds") List<Long> schemaIds);
 
+  @UpdateProvider(
+      type = OwnerMetaSQLProviderFactory.class,
+      method = "softDeleteOwnerRelOfJobsByTemplateId")
+  void softDeleteOwnerRelOfJobsByTemplateId(@Param("jobTemplateId") Long 
jobTemplateId);
+
   @UpdateProvider(
       type = OwnerMetaSQLProviderFactory.class,
       method = "deleteOwnerMetasByLegacyTimeline")
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
index eea8ddb9d3..bcdd7da2a8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
@@ -189,6 +189,11 @@ public class OwnerMetaSQLProviderFactory {
     return getProvider().softDeleteOwnerRelBySchemaIds(schemaIds);
   }
 
+  public static String softDeleteOwnerRelOfJobsByTemplateId(
+      @Param("jobTemplateId") Long jobTemplateId) {
+    return getProvider().softDeleteOwnerRelOfJobsByTemplateId(jobTemplateId);
+  }
+
   public static String deleteOwnerMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
     return getProvider().deleteOwnerMetasByLegacyTimeline(legacyTimeline, 
limit);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
index 46806b59e3..8ce23075c1 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
@@ -25,6 +25,7 @@ import 
org.apache.gravitino.storage.relational.mapper.CatalogMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.FilesetMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.FunctionMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.JobMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.ModelMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.TableMetaMapper;
@@ -284,6 +285,22 @@ public class OwnerMetaBaseSQLProvider {
         + ")";
   }
 
+  public String softDeleteOwnerRelOfJobsByTemplateId(@Param("jobTemplateId") 
Long jobTemplateId) {
+    String now = currentTimestampMillisExpression();
+    // No deleted_at filter on the job rows: the template delete soft-deletes 
its jobs earlier in
+    // the same transaction, and filtering them out would make this a no-op.
+    return "UPDATE "
+        + OWNER_TABLE_NAME
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
+        + " WHERE deleted_at = 0 AND metadata_object_type = 'JOB' AND 
metadata_object_id IN ("
+        + " SELECT job_run_id FROM "
+        + JobMetaMapper.TABLE_NAME
+        + " WHERE job_template_id = #{jobTemplateId})";
+  }
+
   public String softDeleteOwnerRelBySchemaIds(@Param("schemaIds") List<Long> 
schemaIds) {
     String now = currentTimestampMillisExpression();
     return "<script>"
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/JobMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/JobMetaService.java
index a07a13ce0f..fab897cce8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/JobMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/JobMetaService.java
@@ -29,6 +29,7 @@ import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.HasIdentifier;
+import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.exceptions.IllegalNamespaceException;
@@ -37,6 +38,7 @@ import org.apache.gravitino.job.JobHandle;
 import org.apache.gravitino.meta.JobEntity;
 import org.apache.gravitino.metrics.Monitored;
 import org.apache.gravitino.storage.relational.mapper.JobMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
 import org.apache.gravitino.storage.relational.po.JobPO;
 import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
@@ -245,7 +247,7 @@ public class JobMetaService {
         });
   }
 
-  /** Deletes a job only if the observed version is still active. */
+  /** Deletes a job and its owner relation only if the observed version is 
still active. */
   void deleteJobWithVersion(NameIdentifier ident, JobPO observed) {
     SessionUtils.doMultipleWithCommit(
         () ->
@@ -256,7 +258,13 @@ public class JobMetaService {
                         mapper ->
                             mapper.softDeleteJobByRunIdWithVersion(
                                 observed.jobRunId(), 
observed.currentVersion())),
-                () -> writeFailure(ident, observed)));
+                () -> writeFailure(ident, observed)),
+        () ->
+            SessionUtils.doWithoutCommit(
+                OwnerMetaMapper.class,
+                mapper ->
+                    mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
+                        observed.jobRunId(), MetadataObject.Type.JOB.name())));
   }
 
   private RuntimeException writeFailure(NameIdentifier ident, JobPO observed) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/JobTemplateMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/JobTemplateMetaService.java
index 9628fa7f7e..bd69a6230d 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/JobTemplateMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/JobTemplateMetaService.java
@@ -29,6 +29,7 @@ import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.HasIdentifier;
+import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
@@ -38,6 +39,8 @@ import org.apache.gravitino.metrics.Monitored;
 import org.apache.gravitino.storage.relational.EntityChangeLogWriter;
 import org.apache.gravitino.storage.relational.mapper.JobMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.JobTemplateMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
 import org.apache.gravitino.storage.relational.po.JobTemplatePO;
 import org.apache.gravitino.storage.relational.po.cache.OperateType;
 import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
@@ -259,10 +262,28 @@ public class JobTemplateMetaService {
             SessionUtils.doWithoutCommit(
                 JobMetaMapper.class,
                 mapper -> 
mapper.softDeleteJobsByTemplateId(observed.jobTemplateId())),
+        () -> deleteJobTemplateRelations(observed.jobTemplateId()),
         () ->
             EntityChangeLogWriter.append(ident, 
Entity.EntityType.JOB_TEMPLATE, OperateType.DROP));
   }
 
+  private static void deleteJobTemplateRelations(Long jobTemplateId) {
+    SessionUtils.doWithoutCommit(
+        OwnerMetaMapper.class,
+        mapper ->
+            mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
+                jobTemplateId, MetadataObject.Type.JOB_TEMPLATE.name()));
+    SessionUtils.doWithoutCommit(
+        SecurableObjectMapper.class,
+        mapper ->
+            mapper.softDeleteObjectRelsByMetadataObject(
+                jobTemplateId, MetadataObject.Type.JOB_TEMPLATE.name()));
+    // The template's jobs were deleted with it. Jobs have owners but can't be 
securable objects.
+    SessionUtils.doWithoutCommit(
+        OwnerMetaMapper.class,
+        mapper -> mapper.softDeleteOwnerRelOfJobsByTemplateId(jobTemplateId));
+  }
+
   /** Locks the observed template while a job is inserted in the same 
transaction. */
   void lockTemplateForJobWrite(String name, Long templateId, Long metalakeId) {
     OccWriteSupport.lockParentForChildWrite(
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
index b9ac73e877..5d58fe6900 100644
--- 
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
@@ -63,6 +63,7 @@ import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.TestCatalog;
 import org.apache.gravitino.TestColumn;
+import org.apache.gravitino.TestTable;
 import org.apache.gravitino.auth.AuthConstants;
 import org.apache.gravitino.connector.HiddenPropertyMaskUtils;
 import org.apache.gravitino.connector.TestCatalogOperations;
@@ -1492,6 +1493,142 @@ public class TestTableOperationDispatcher extends 
TestOperationDispatcher {
         .build();
   }
 
+  @Test
+  public void testLoadFailsWhenCatalogReportsNoColumns() throws IOException {
+    NameIdentifier tableIdent = 
createTableReportingNoColumns("schema_null_columns_load");
+    Map<String, Long> ids = columnIds(tableIdent);
+
+    IllegalStateException e =
+        Assertions.assertThrows(
+            IllegalStateException.class, () -> 
tableOperationDispatcher.loadTable(tableIdent));
+
+    Assertions.assertTrue(e.getMessage().contains("without a column list"), 
e.getMessage());
+    Assertions.assertTrue(e.getMessage().contains(catalog), e.getMessage());
+    Assertions.assertEquals(ids, columnIds(tableIdent));
+  }
+
+  @Test
+  public void testAlterKeepsStoredColumnsWhenCatalogReportsNoColumns() throws 
IOException {
+    NameIdentifier tableIdent = 
createTableReportingNoColumns("schema_null_columns_alter");
+    Map<String, Long> ids = columnIds(tableIdent);
+
+    Table altered =
+        tableOperationDispatcher.alterTable(tableIdent, 
TableChange.setProperty("k3", "v3"));
+
+    // The catalog applied the change, the stored columns are kept and 
reported instead.
+    Assertions.assertEquals(
+        "v3", 
testCatalogOperations().loadTable(tableIdent).properties().get("k3"));
+    Assertions.assertEquals(ids, columnIds(tableIdent));
+    Assertions.assertArrayEquals(
+        new String[] {"c1"}, 
Arrays.stream(altered.columns()).map(Column::name).toArray());
+  }
+
+  @Test
+  public void testRenameCompletesWhenCatalogReportsNoColumns() throws 
IOException {
+    NameIdentifier tableIdent = 
createTableReportingNoColumns("schema_null_columns_rename");
+    Map<String, Long> ids = columnIds(tableIdent);
+    NameIdentifier newIdent = NameIdentifier.of(tableIdent.namespace(), 
"table_null_columns_new");
+
+    Table altered =
+        tableOperationDispatcher.alterTable(tableIdent, 
TableChange.rename(newIdent.name()));
+
+    // The rename completes, so the hook's authorization rename that follows 
it runs too.
+    Assertions.assertEquals(newIdent.name(), altered.name());
+    Assertions.assertArrayEquals(
+        new String[] {"c1"}, 
Arrays.stream(altered.columns()).map(Column::name).toArray());
+    Assertions.assertFalse(entityStore.exists(tableIdent, TABLE));
+    Assertions.assertEquals(ids, columnIds(newIdent));
+  }
+
+  @Test
+  public void testAlterUnstoredTableFailsWhenCatalogReportsNoColumns() {
+    Namespace tableNs = Namespace.of(metalake, catalog, 
"schema_null_columns_unstored");
+    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+    
schemaOperationDispatcher.createSchema(NameIdentifier.of(tableNs.levels()), 
"comment", props);
+    NameIdentifier tableIdent = NameIdentifier.of(tableNs, 
"table_null_columns");
+    // Created in the catalog only, so Gravitino has no stored table and no id 
for it.
+    testCatalogOperations()
+        .replaceTable(
+            tableIdent,
+            TestTable.builder()
+                .withName(tableIdent.name())
+                .withProperties(props)
+                .withAuditInfo(
+                    
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+                .build());
+
+    Assertions.assertThrows(
+        IllegalStateException.class,
+        () -> tableOperationDispatcher.alterTable(tableIdent, 
TableChange.setProperty("k3", "v3")));
+    Assertions.assertEquals(
+        "v3", 
testCatalogOperations().loadTable(tableIdent).properties().get("k3"));
+  }
+
+  @Test
+  public void testCreateFailsWhenCatalogReportsNoColumns() throws IOException {
+    Namespace tableNs = Namespace.of(metalake, catalog, 
"schema_null_columns_create");
+    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+    
schemaOperationDispatcher.createSchema(NameIdentifier.of(tableNs.levels()), 
"comment", props);
+    NameIdentifier tableIdent = NameIdentifier.of(tableNs, 
"table_null_columns");
+    Column[] columns =
+        new Column[] {
+          TestColumn.builder()
+              .withName("c1")
+              .withPosition(0)
+              .withType(Types.IntegerType.get())
+              .build()
+        };
+
+    TestCatalogOperations ops = testCatalogOperations();
+    ops.setReportNoColumnsOnCreate(true);
+    try {
+      Assertions.assertThrows(
+          IllegalStateException.class,
+          () ->
+              tableOperationDispatcher.createTable(
+                  tableIdent, columns, "comment", props, new Transform[0]));
+    } finally {
+      ops.setReportNoColumnsOnCreate(false);
+    }
+
+    Assertions.assertFalse(entityStore.exists(tableIdent, TABLE));
+  }
+
+  private NameIdentifier createTableReportingNoColumns(String schemaName) {
+    Namespace tableNs = Namespace.of(metalake, catalog, schemaName);
+    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+    
schemaOperationDispatcher.createSchema(NameIdentifier.of(tableNs.levels()), 
"comment", props);
+    NameIdentifier tableIdent = NameIdentifier.of(tableNs, 
"table_null_columns");
+    Column[] columns =
+        new Column[] {
+          TestColumn.builder()
+              .withName("c1")
+              .withPosition(0)
+              .withType(Types.IntegerType.get())
+              .build()
+        };
+    tableOperationDispatcher.createTable(tableIdent, columns, "comment", 
props, new Transform[0]);
+
+    testCatalogOperations()
+        .replaceTable(
+            tableIdent,
+            TestTable.builder()
+                .withName(tableIdent.name())
+                .withComment("comment")
+                .withProperties(props)
+                .withAuditInfo(
+                    
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+                .build());
+    return tableIdent;
+  }
+
+  private TestCatalogOperations testCatalogOperations() {
+    TestCatalog testCatalog =
+        (TestCatalog)
+            catalogManager.loadCatalogAndWrap(NameIdentifier.of(metalake, 
catalog)).catalog();
+    return (TestCatalogOperations) testCatalog.ops();
+  }
+
   @Test
   public void testRenameIntoNameOfStaleStoredColumn() throws IOException {
     Namespace tableNs = Namespace.of(metalake, catalog, "schema_stale_column");
diff --git 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
index 3ac659243c..b56272877e 100644
--- 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
+++ 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
@@ -132,6 +132,8 @@ public class TestCatalogOperations
 
   private final Map<String, String> config;
 
+  private boolean reportNoColumnsOnCreate;
+
   public static final String FAIL_CREATE = "fail-create";
 
   public static final String FAIL_TEST = "need-fail";
@@ -173,6 +175,16 @@ public class TestCatalogOperations
         .toArray(NameIdentifier[]::new);
   }
 
+  /** Makes {@link #createTable} return a table without a column list, as a 
broken source would. */
+  public void setReportNoColumnsOnCreate(boolean reportNoColumnsOnCreate) {
+    this.reportNoColumnsOnCreate = reportNoColumnsOnCreate;
+  }
+
+  /** Replaces a table, e.g. with one whose source reports no column list. */
+  public void replaceTable(NameIdentifier ident, TestTable table) {
+    tables.put(ident, table);
+  }
+
   @Override
   public Table loadTable(NameIdentifier ident) throws NoSuchTableException {
     if (tables.containsKey(ident)) {
@@ -236,7 +248,7 @@ public class TestCatalogOperations
         .withComment(comment)
         .withProperties(new HashMap<>(properties))
         .withAuditInfo(auditInfo)
-        .withColumns(sortedColumns)
+        .withColumns(reportNoColumnsOnCreate ? null : sortedColumns)
         .withDistribution(distribution)
         .withSortOrders(sortOrders)
         .withPartitioning(partitions)
@@ -1454,6 +1466,9 @@ public class TestCatalogOperations
   }
 
   private Column[] updateColumns(Column[] columns, TableChange.ColumnChange[] 
columnChanges) {
+    if (columns == null) {
+      return null;
+    }
     Map<String, Column> columnMap =
         Arrays.stream(columns).collect(Collectors.toMap(Column::name, 
Function.identity()));
 
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
index 894937a1e6..bce9a024e3 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
@@ -342,6 +342,24 @@ public abstract class TestJDBCBackend {
     }
   }
 
+  protected int countActiveRowsWhere(String table, String whereClause) {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement();
+        ResultSet rs =
+            statement.executeQuery(
+                String.format(
+                    "SELECT count(*) FROM %s WHERE %s AND deleted_at = 0", 
table, whereClause))) {
+      if (rs.next()) {
+        return rs.getInt(1);
+      }
+      throw new RuntimeException("Doesn't contain data");
+    } catch (SQLException se) {
+      throw new RuntimeException("SQL execution failed", se);
+    }
+  }
+
   protected BaseMetalake createBaseMakeLake(Long id, String name, AuditInfo 
auditInfo) {
     return BaseMetalake.builder()
         .withId(id)
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java
index abd5802a53..20d33937d6 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java
@@ -22,14 +22,17 @@ import java.io.IOException;
 import java.time.Instant;
 import java.util.List;
 import org.apache.commons.lang3.ArrayUtils;
+import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.job.JobHandle;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.JobEntity;
 import org.apache.gravitino.meta.JobTemplateEntity;
+import org.apache.gravitino.meta.UserEntity;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
 import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -231,6 +234,45 @@ public class TestJobMetaService extends TestJDBCBackend {
             .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, job.name())));
   }
 
+  @TestTemplate
+  public void testDeleteJobCleansOwner() throws IOException {
+    BaseMetalake metalake =
+        createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), METALAKE_NAME, 
AUDIT_INFO);
+    backend.insert(metalake, false);
+    backend.insert(newShellJobTemplateEntity("template", "", METALAKE_NAME), 
false);
+    UserEntity user =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(METALAKE_NAME),
+            "job_owner",
+            AUDIT_INFO);
+    backend.insert(user, false);
+
+    JobEntity dropped =
+        TestJobTemplateMetaService.newJobEntity(
+            "template", JobHandle.Status.SUCCEEDED, METALAKE_NAME);
+    JobEntity kept =
+        TestJobTemplateMetaService.newJobEntity(
+            "template", JobHandle.Status.SUCCEEDED, METALAKE_NAME);
+    backend.insert(dropped, false);
+    backend.insert(kept, false);
+    for (JobEntity job : new JobEntity[] {dropped, kept}) {
+      OwnerMetaService.getInstance()
+          .setOwner(
+              NameIdentifierUtil.ofJob(METALAKE_NAME, job.name()),
+              Entity.EntityType.JOB,
+              user.nameIdentifier(),
+              user.type());
+    }
+
+    Assertions.assertTrue(
+        JobMetaService.getInstance()
+            .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, 
dropped.name())));
+
+    Assertions.assertEquals(0, countJobOwnerRows(dropped.id()));
+    Assertions.assertEquals(1, countJobOwnerRows(kept.id()));
+  }
+
   @TestTemplate
   public void testUpdateJob() throws IOException {
     BaseMetalake metalake =
@@ -491,4 +533,10 @@ public class TestJobMetaService extends TestJDBCBackend {
                             .withFinishedAt(oldJob.finishedAt())
                             .build()));
   }
+
+  private int countJobOwnerRows(long jobId) {
+    return countActiveRowsWhere(
+        "owner_meta",
+        String.format("metadata_object_id = %d AND metadata_object_type = 
'JOB'", jobId));
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
index 31ed1f9100..aca0ea972a 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
@@ -18,12 +18,17 @@
  */
 package org.apache.gravitino.storage.relational.service;
 
+import com.google.common.collect.Lists;
 import java.io.IOException;
 import java.time.Instant;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.authorization.SecurableObjects;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NonEmptyEntityException;
 import org.apache.gravitino.job.JobHandle;
@@ -31,6 +36,7 @@ import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.JobEntity;
 import org.apache.gravitino.meta.JobTemplateEntity;
+import org.apache.gravitino.meta.UserEntity;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import 
org.apache.gravitino.storage.relational.EntityChangeLogNameIdentifierCodec;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
@@ -232,6 +238,66 @@ public class TestJobTemplateMetaService extends 
TestJDBCBackend {
     Assertions.assertEquals(0, jobs.size());
   }
 
+  @TestTemplate
+  public void testDeleteJobTemplateCleansOwnersAndPrivileges() throws 
IOException {
+    BaseMetalake metalake =
+        createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), METALAKE_NAME, 
AUDIT_INFO);
+    backend.insert(metalake, false);
+    UserEntity user =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(METALAKE_NAME),
+            "job_template_owner",
+            AUDIT_INFO);
+    backend.insert(user, false);
+
+    JobTemplateEntity dropped = newShellJobTemplateEntity("dropped", "", 
METALAKE_NAME);
+    JobTemplateEntity kept = newShellJobTemplateEntity("kept", "", 
METALAKE_NAME);
+    backend.insert(dropped, false);
+    backend.insert(kept, false);
+    JobEntity droppedJob = newJobEntity("dropped", JobHandle.Status.SUCCEEDED, 
METALAKE_NAME);
+    JobEntity keptJob = newJobEntity("kept", JobHandle.Status.SUCCEEDED, 
METALAKE_NAME);
+    backend.insert(droppedJob, false);
+    backend.insert(keptJob, false);
+
+    OwnerMetaService ownerMetaService = OwnerMetaService.getInstance();
+    for (NameIdentifier ident :
+        new NameIdentifier[] {dropped.nameIdentifier(), 
kept.nameIdentifier()}) {
+      ownerMetaService.setOwner(
+          ident, Entity.EntityType.JOB_TEMPLATE, user.nameIdentifier(), 
user.type());
+    }
+    for (JobEntity job : new JobEntity[] {droppedJob, keptJob}) {
+      ownerMetaService.setOwner(
+          NameIdentifierUtil.ofJob(METALAKE_NAME, job.name()),
+          Entity.EntityType.JOB,
+          user.nameIdentifier(),
+          user.type());
+    }
+    backend.insert(
+        createRoleEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+            "job_template_role",
+            AUDIT_INFO,
+            Lists.newArrayList(
+                SecurableObjects.ofJobTemplate(
+                    "dropped", 
Lists.newArrayList(Privileges.UseJobTemplate.allow())),
+                SecurableObjects.ofJobTemplate(
+                    "kept", 
Lists.newArrayList(Privileges.UseJobTemplate.allow()))),
+            null),
+        false);
+
+    Assertions.assertTrue(
+        
JobTemplateMetaService.getInstance().deleteJobTemplate(dropped.nameIdentifier()));
+
+    Assertions.assertEquals(0, countOwnerRows(dropped.id(), "JOB_TEMPLATE"));
+    Assertions.assertEquals(0, countSecurableObjectRows(dropped.id(), 
"JOB_TEMPLATE"));
+    Assertions.assertEquals(0, countOwnerRows(droppedJob.id(), "JOB"));
+    Assertions.assertEquals(1, countOwnerRows(kept.id(), "JOB_TEMPLATE"));
+    Assertions.assertEquals(1, countSecurableObjectRows(kept.id(), 
"JOB_TEMPLATE"));
+    Assertions.assertEquals(1, countOwnerRows(keptJob.id(), "JOB"));
+  }
+
   @TestTemplate
   public void testUpdateJobTemplate() throws IOException {
     BaseMetalake metalake =
@@ -376,6 +442,19 @@ public class TestJobTemplateMetaService extends 
TestJDBCBackend {
     Assertions.assertEquals(operateType, change.getOperateType());
   }
 
+  private int countOwnerRows(long objectId, String objectType) {
+    return countActiveRowsWhere(
+        "owner_meta",
+        String.format(
+            "metadata_object_id = %d AND metadata_object_type = '%s'", 
objectId, objectType));
+  }
+
+  private int countSecurableObjectRows(long objectId, String objectType) {
+    return countActiveRowsWhere(
+        "role_meta_securable_object",
+        String.format("metadata_object_id = %d AND type = '%s'", objectId, 
objectType));
+  }
+
   static JobEntity newJobEntity(String templateName, JobHandle.Status status, 
String metalake) {
     // Any status other than QUEUED implies the job has at least started.
     boolean isStarted = status != JobHandle.Status.QUEUED;

Reply via email to