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

j1wonpark pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git


The following commit(s) were added to refs/heads/master by this push:
     new 43af0c9d6 [AMORO-4441][AMS] Close optimizing processes with 
incompatible persisted task outputs (#4442)
43af0c9d6 is described below

commit 43af0c9d68d584503457ca2430093c251aaad5b9
Author: Jiwon Park <[email protected]>
AuthorDate: Thu Oct 15 14:46:03 2026 +0900

    [AMORO-4441][AMS] Close optimizing processes with incompatible persisted 
task outputs (#4442)
    
    [AMS] Close optimizing processes with incompatible persisted task outputs
    
    Signed-off-by: Jiwon Park <[email protected]>
---
 .../amoro/server/optimizing/OptimizingQueue.java   |  14 +
 .../optimizing/TestOptimizingTaskRecovery.java     | 369 +++++++++++++++++++++
 2 files changed, 383 insertions(+)

diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java
index 2f7ba59c1..d5815a0e8 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java
@@ -72,6 +72,7 @@ import org.apache.iceberg.util.StructLikeMap;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.InvalidClassException;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedList;
@@ -1090,6 +1091,19 @@ public class OptimizingQueue extends PersistentBase {
                 retryTask(taskRuntime);
               }
             });
+      } catch (PersistenceException e) {
+        Throwable cause = e;
+        while (cause != null && !(cause instanceof InvalidClassException)) {
+          cause = cause.getCause();
+        }
+        if (cause == null) {
+          throw e;
+        }
+        LOG.warn(
+            "Incompatible persisted task output, close the optimizing process 
: {}",
+            optimizingProcess.getProcessId(),
+            e);
+        optimizingProcess.close(false);
       } catch (IllegalArgumentException e) {
         LOG.warn(
             "Load task inputs failed, close the optimizing process : {}",
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingTaskRecovery.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingTaskRecovery.java
new file mode 100644
index 000000000..3a6915965
--- /dev/null
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingTaskRecovery.java
@@ -0,0 +1,369 @@
+/*
+ * 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.amoro.server.optimizing;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import org.apache.amoro.ServerTableIdentifier;
+import org.apache.amoro.TableFormat;
+import org.apache.amoro.api.OptimizingTaskResult;
+import org.apache.amoro.exception.PersistenceException;
+import org.apache.amoro.formats.iceberg.IcebergTable;
+import org.apache.amoro.formats.iceberg.IcebergV3TestTables;
+import org.apache.amoro.io.IcebergDataTestHelpers;
+import org.apache.amoro.optimizing.RewriteFilesOutput;
+import org.apache.amoro.process.ProcessStatus;
+import org.apache.amoro.resource.ResourceGroup;
+import org.apache.amoro.server.catalog.CatalogManager;
+import org.apache.amoro.server.persistence.NestedSqlSession;
+import org.apache.amoro.server.persistence.PersistentBase;
+import org.apache.amoro.server.persistence.SqlSessionFactoryProvider;
+import org.apache.amoro.server.persistence.TableRuntimeMeta;
+import org.apache.amoro.server.persistence.mapper.OptimizingProcessMapper;
+import org.apache.amoro.server.persistence.mapper.TableMetaMapper;
+import org.apache.amoro.server.persistence.mapper.TableProcessMapper;
+import org.apache.amoro.server.persistence.mapper.TableRuntimeMapper;
+import org.apache.amoro.server.resource.OptimizerThread;
+import org.apache.amoro.server.table.DefaultTableRuntime;
+import org.apache.amoro.server.table.DefaultTableRuntimeStore;
+import org.apache.amoro.server.table.DerbyPersistence;
+import org.apache.amoro.server.utils.CompressUtil;
+import org.apache.amoro.table.TableMetaStore;
+import org.apache.amoro.table.TableProperties;
+import org.apache.amoro.table.UnkeyedTable;
+import org.apache.amoro.utils.SerializationUtil;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.iceberg.DeleteFile;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.InvalidClassException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** Verifies recovery after a persisted task output becomes incompatible with 
the current class. */
+class TestOptimizingTaskRecovery extends PersistentBase {
+  @TempDir private Path temp;
+
+  @SuppressWarnings("unused")
+  private final DerbyPersistence standalonePersistence = 
initializePersistence();
+
+  private final TestPersistence persistence = new TestPersistence();
+  private final CatalogManager catalogManager = mock(CatalogManager.class);
+  private final Set<Long> tableIds = new HashSet<>();
+  private OptimizingQueue queue;
+
+  private static DerbyPersistence initializePersistence() {
+    try {
+      SqlSessionFactoryProvider.getInstance().get();
+      return null;
+    } catch (IllegalStateException e) {
+      return new DerbyPersistence();
+    }
+  }
+
+  @AfterEach
+  void cleanUp() {
+    if (queue != null) {
+      queue.dispose();
+    }
+    persistence.cleanUp(tableIds);
+    tableIds.clear();
+  }
+
+  @Test
+  void testRecoveryClosesProcessWithIncompatibleTaskOutput() throws Exception {
+    DefaultTableRuntime runtime = createRuntime("task_recovery");
+    UnkeyedTable table = (UnkeyedTable) runtime.loadTable().originalTable();
+    queue = createQueue(runtime);
+    OptimizerThread worker = thread(1);
+    TaskRuntime<?> task = queue.pollTask(worker, 0);
+    assertNotNull(task);
+    long processId = runtime.getProcessId();
+    queue.ackTask(task.getTaskId(), worker);
+    RewriteFilesOutput output =
+        new RewriteFilesOutput(
+            IcebergDataTestHelpers.insert(table, 
IcebergV3TestTables.records(table, 1, 2))
+                .dataFiles(),
+            new DeleteFile[0],
+            null);
+    OptimizingTaskResult result = new OptimizingTaskResult(task.getTaskId(), 
worker.getThreadId());
+    result.setTaskOutput(SerializationUtil.simpleSerialize(output));
+    queue.completeTask(worker, result);
+    // Restart after persisting the result but before persisting the 
COMMITTING transition.
+    runtime
+        .store()
+        .begin()
+        .updateStatusCode(ignored -> 
OptimizingStatus.MINOR_OPTIMIZING.getCode())
+        .commit();
+    queue.dispose();
+    queue = null;
+
+    assertEquals(
+        ProcessStatus.RUNNING,
+        getAs(TableProcessMapper.class, mapper -> 
mapper.getProcessMeta(processId)).getStatus());
+    assertEquals(
+        OptimizingStatus.MINOR_OPTIMIZING.getCode(),
+        getAs(
+                TableRuntimeMapper.class,
+                mapper -> 
mapper.selectRuntime(runtime.getTableIdentifier().getId()))
+            .getStatusCode());
+    changePersistedOutputSerialVersionUid(processId);
+    PersistenceException failure =
+        assertThrows(
+            PersistenceException.class,
+            () ->
+                getAs(
+                    OptimizingProcessMapper.class,
+                    mapper ->
+                        mapper.selectTaskRuntimes(
+                            runtime.getTableIdentifier().getId(), processId)));
+    Throwable rootCause = failure;
+    while (rootCause.getCause() != null) {
+      rootCause = rootCause.getCause();
+    }
+    assertEquals(InvalidClassException.class, rootCause.getClass());
+    assertTrue(rootCause.getMessage().contains("org.apache.iceberg.BaseFile"));
+
+    // Reconstruct the runtime from the DB as well, rather than reusing the 
in-memory process.
+    ServerTableIdentifier identifier = runtime.getTableIdentifier();
+    DefaultTableRuntime restored = restoreRuntime(runtime);
+    queue = createQueue(restored);
+    assertAll(
+        () ->
+            assertEquals(
+                ProcessStatus.CLOSED,
+                getAs(TableProcessMapper.class, mapper -> 
mapper.getProcessMeta(processId))
+                    .getStatus(),
+                "An unreadable process must be closed persistently"),
+        () ->
+            assertEquals(
+                OptimizingStatus.IDLE,
+                restored.getOptimizingStatus(),
+                "An unreadable task must not leave the table optimizing"),
+        () ->
+            assertTrue(
+                
queue.getSchedulingPolicy().getTableRuntimeMap().containsKey(identifier),
+                "The table must be eligible for planning again"));
+  }
+
+  @Test
+  void testRecoveryDoesNotCloseProcessOnDatabaseFailure() throws Exception {
+    DefaultTableRuntime runtime = createRuntime("database_failure");
+    queue = createQueue(runtime);
+    assertNotNull(queue.pollTask(thread(1), 0));
+    long processId = runtime.getProcessId();
+    OptimizingStatus status = runtime.getOptimizingStatus();
+    queue.dispose();
+    queue = null;
+    DefaultTableRuntime restored = restoreRuntime(runtime);
+    long tableId = runtime.getTableIdentifier().getId();
+
+    // Fail only the task query, after the process metadata has been loaded 
successfully.
+    try (SqlSession sql = 
SqlSessionFactoryProvider.getInstance().get().openSession()) {
+      SqlSession session = spy(sql);
+      OptimizingProcessMapper mapper = 
spy(sql.getMapper(OptimizingProcessMapper.class));
+      doThrow(
+              new org.apache.ibatis.exceptions.PersistenceException(
+                  "Task query failed", new SQLException("Database 
unavailable")))
+          .when(mapper)
+          .selectTaskRuntimes(tableId, processId);
+      doReturn(mapper).when(session).getMapper(OptimizingProcessMapper.class);
+      try (NestedSqlSession ignored = NestedSqlSession.openSession(() -> 
session)) {
+        queue = createQueue(restored);
+        verify(mapper).selectTaskRuntimes(tableId, processId);
+      }
+    }
+
+    assertAll(
+        () ->
+            assertEquals(
+                ProcessStatus.RUNNING,
+                getAs(TableProcessMapper.class, mapper -> 
mapper.getProcessMeta(processId))
+                    .getStatus()),
+        () -> assertEquals(status, restored.getOptimizingStatus()),
+        () -> assertEquals(processId, restored.getProcessId()));
+  }
+
+  private DefaultTableRuntime restoreRuntime(DefaultTableRuntime runtime) {
+    ServerTableIdentifier identifier = runtime.getTableIdentifier();
+    return new DefaultTableRuntime(
+        new DefaultTableRuntimeStore(
+            identifier,
+            getAs(TableRuntimeMapper.class, mapper -> 
mapper.selectRuntime(identifier.getId())),
+            DefaultTableRuntime.REQUIRED_STATES,
+            getAs(TableRuntimeMapper.class, 
TableRuntimeMapper::selectAllStates).stream()
+                .filter(state -> state.getTableId() == identifier.getId())
+                .collect(Collectors.toList())),
+        runtime::loadTable);
+  }
+
+  private void changePersistedOutputSerialVersionUid(long processId) throws 
SQLException {
+    try (SqlSession session = 
SqlSessionFactoryProvider.getInstance().get().openSession(true)) {
+      byte[] serialized;
+      try (PreparedStatement select =
+          session
+              .getConnection()
+              .prepareStatement("SELECT rewrite_output FROM task_runtime WHERE 
process_id = ?")) {
+        select.setLong(1, processId);
+        try (ResultSet rows = select.executeQuery()) {
+          assertTrue(rows.next());
+          serialized = CompressUtil.unGzip(rows.getBytes(1));
+          assertFalse(rows.next(), "The fixture must contain exactly one 
task");
+        }
+      }
+      // Keep a valid object stream and GZIP envelope; change only the class 
descriptor UID.
+      // This models a binary-incompatible upgrade without tying the test to 
an Iceberg release.
+      assertNotNull(SerializationUtil.simpleDeserialize(serialized));
+      byte[] className = 
"org.apache.iceberg.BaseFile".getBytes(StandardCharsets.UTF_8);
+      int changed = 0;
+      for (int i = 3; i + className.length + Long.BYTES <= serialized.length; 
i++) {
+        if (serialized[i - 3] == 0x72
+            && serialized[i - 2] == 0
+            && serialized[i - 1] == className.length
+            && Arrays.equals(serialized, i, i + className.length, className, 
0, className.length)) {
+          serialized[i + className.length] ^= 1;
+          changed++;
+        }
+      }
+      assertEquals(1, changed, "Expected one BaseFile class descriptor");
+      try (PreparedStatement update =
+          session
+              .getConnection()
+              .prepareStatement(
+                  "UPDATE task_runtime SET rewrite_output = ? WHERE process_id 
= ?")) {
+        update.setBytes(1, CompressUtil.gzip(serialized));
+        update.setLong(2, processId);
+        assertEquals(1, update.executeUpdate());
+      }
+    }
+  }
+
+  private DefaultTableRuntime createRuntime(String name) throws Exception {
+    UnkeyedTable table = IcebergV3TestTables.create(temp.resolve(name), 2);
+    table
+        .updateProperties()
+        .set("self-optimizing.enabled", "true")
+        .set(TableProperties.SELF_OPTIMIZING_MINOR_TRIGGER_FILE_CNT, "2")
+        .commit();
+    ServerTableIdentifier identifier =
+        ServerTableIdentifier.of("test_catalog", "test_database", name, 
TableFormat.ICEBERG);
+    IcebergTable amoroTable =
+        IcebergTable.newIcebergTable(
+            identifier.getIdentifier(),
+            table,
+            TableMetaStore.builder().withConfiguration(new 
Configuration()).build(),
+            Collections.emptyMap());
+    
doReturn(amoroTable).when(catalogManager).loadTable(identifier.getIdentifier());
+
+    TableRuntimeMeta meta = new TableRuntimeMeta();
+    meta.setGroupName("default");
+    meta.setStatusCode(OptimizingStatus.PENDING.getCode());
+    doAs(TableMetaMapper.class, mapper -> mapper.insertTable(identifier));
+    tableIds.add(identifier.getId());
+    meta.setTableId(identifier.getId());
+    doAs(TableRuntimeMapper.class, mapper -> mapper.insertRuntime(meta));
+    DefaultTableRuntimeStore store =
+        new DefaultTableRuntimeStore(
+            identifier, meta, DefaultTableRuntime.REQUIRED_STATES, 
Collections.emptyList());
+    DefaultTableRuntime runtime = new DefaultTableRuntime(store, () -> 
amoroTable);
+    runtime.refresh(amoroTable);
+    assertTrue(runtime.getOptimizingConfig().isEnabled());
+    assertEquals(2, 
IcebergV3TestTables.dataFiles(amoroTable.originalTable()).size());
+    return runtime;
+  }
+
+  private OptimizingQueue createQueue(DefaultTableRuntime runtime) {
+    // Run planning synchronously. pollTask(..., 0) still drives planning and 
then
+    // fetches the produced task, without sleeps or a background executor.
+    return new OptimizingQueue(
+        catalogManager,
+        new ResourceGroup.Builder("default", "local").build(),
+        group -> 1,
+        Runnable::run,
+        Collections.singletonList(runtime),
+        1);
+  }
+
+  private OptimizerThread thread(int id) {
+    return new OptimizerThread(id, null) {
+      @Override
+      public String getToken() {
+        return "recovery-test";
+      }
+    };
+  }
+
+  private static class TestPersistence {
+    private static final String[] TABLES_WITH_TABLE_ID = {
+      "task_runtime",
+      "optimizing_task_quota",
+      "optimizing_process_state",
+      "table_process_state",
+      "table_process",
+      "table_runtime_state",
+      "table_runtime",
+      "table_metadata",
+      "table_identifier"
+    };
+
+    void cleanUp(Set<Long> tableIds) {
+      if (tableIds.isEmpty()) {
+        return;
+      }
+
+      try (SqlSession session = 
SqlSessionFactoryProvider.getInstance().get().openSession(true)) {
+        for (String table : TABLES_WITH_TABLE_ID) {
+          try (PreparedStatement statement =
+              session
+                  .getConnection()
+                  .prepareStatement("DELETE FROM " + table + " WHERE table_id 
= ?")) {
+            for (Long tableId : tableIds) {
+              statement.setLong(1, tableId);
+              statement.addBatch();
+            }
+            statement.executeBatch();
+          }
+        }
+      } catch (SQLException e) {
+        throw new RuntimeException("Failed to clean up optimizing recovery 
test data", e);
+      }
+    }
+  }
+}

Reply via email to