This is an automated email from the ASF dual-hosted git repository.
zhoujinsong 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 acb9a49a4 [AMORO-4300] Recover active processes after table ownership
transfer (#4304)
acb9a49a4 is described below
commit acb9a49a493fc0708e186a7da6bac9634deef895
Author: Xu Bai <[email protected]>
AuthorDate: Thu Jul 30 19:11:45 2026 +0800
[AMORO-4300] Recover active processes after table ownership transfer (#4304)
[AMORO-4300][ams] Recover active processes after table ownership transfer
Co-authored-by: ZhouJinsong <[email protected]>
---
.../persistence/mapper/TableProcessMapper.java | 6 +-
.../amoro/server/process/ProcessService.java | 80 +++++---
.../amoro/server/TestDefaultProcessService.java | 209 ++++++++++++++++++++-
3 files changed, 266 insertions(+), 29 deletions(-)
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/TableProcessMapper.java
b/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/TableProcessMapper.java
index ba21e01ff..21d8d14e3 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/TableProcessMapper.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/TableProcessMapper.java
@@ -152,7 +152,9 @@ public interface TableProcessMapper {
@Select(
"SELECT process_id, table_id, external_process_identifier, status,
process_type, process_stage, execution_engine, retry_number, "
+ "create_time, finish_time, fail_message, process_parameters,
summary "
- + "FROM table_process WHERE status in ('SUBMITTED', 'RUNNING')")
+ + "FROM table_process WHERE table_id in (#{tableIds::number[]}) "
+ + "AND status in ('SUBMITTED', 'RUNNING')")
+ @Lang(InListExtendedLanguageDriver.class)
@ResultMap("tableProcessMap")
- List<TableProcessMeta> selectAllActiveProcesses();
+ List<TableProcessMeta> selectActiveProcesses(@Param("tableIds")
Collection<Long> tableIds);
}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/process/ProcessService.java
b/amoro-ams/src/main/java/org/apache/amoro/server/process/ProcessService.java
index b166ada7a..957e3bf7b 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/process/ProcessService.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/process/ProcessService.java
@@ -45,6 +45,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
@@ -73,6 +74,9 @@ public class ProcessService extends PersistentBase {
private final Map<ServerTableIdentifier, Map<Long, TableProcessHolder>>
activeTableProcess =
new ConcurrentHashMap<>();
+ // Guards against concurrent recovery of the same processId (e.g. startup
recovery racing with
+ // handleTableAdded).
+ private final Set<Long> recoveringProcessIds = ConcurrentHashMap.newKeySet();
public ProcessService(TableService tableService) {
this(tableService, Collections.emptyList(), new ExecuteEngineManager());
@@ -207,8 +211,14 @@ public class ProcessService extends PersistentBase {
Map<Long, TableRuntime> tableIdToRuntimes =
tableRuntimes.stream()
.collect(Collectors.toMap(t -> t.getTableIdentifier().getId(), t
-> t));
+ // InListExtendedLanguageDriver requires at least one value to produce a
valid IN clause.
+ if (tableIdToRuntimes.isEmpty()) {
+ return;
+ }
List<TableProcessMeta> activeProcesses =
- getAs(TableProcessMapper.class,
TableProcessMapper::selectAllActiveProcesses);
+ getAs(
+ TableProcessMapper.class,
+ mapper ->
mapper.selectActiveProcesses(tableIdToRuntimes.keySet()));
activeProcesses.forEach(
processMeta -> {
TableRuntime tableRuntime =
tableIdToRuntimes.get(processMeta.getTableId());
@@ -237,7 +247,7 @@ public class ProcessService extends PersistentBase {
/**
* Recover a single persisted process record. Any failure is contained here:
the offending record
* is marked {@link ProcessStatus#FAILED} and skipped, so that one
un-recoverable process record
- * cannot abort the whole AMS startup (see AMORO-4223). The affected
maintenance action will be
+ * cannot abort process recovery (see AMORO-4223). The affected maintenance
action will be
* re-scheduled by its periodic scheduler.
*
* @param tableRuntime table runtime
@@ -248,32 +258,51 @@ public class ProcessService extends PersistentBase {
TableRuntime tableRuntime,
ActionCoordinatorScheduler scheduler,
TableProcessMeta processMeta) {
- DefaultTableProcessStore store =
- new DefaultTableProcessStore(
- processMeta.getProcessId(),
- tableRuntime,
- processMeta,
- scheduler.getAction(),
- processMeta.getRetryNumber());
+ long processId = processMeta.getProcessId();
+ if (!recoveringProcessIds.add(processId)) {
+ LOG.debug(
+ "Table process {} for table {} is already being recovered, skipping
duplicate recovery.",
+ processId,
+ tableRuntime.getTableIdentifier());
+ return;
+ }
try {
- TableProcess process = scheduler.recover(tableRuntime, store);
- trackTableProcess(tableRuntime.getTableIdentifier(), store, process);
- executeOrTraceProcess(store, process);
- } catch (Throwable t) {
- LOG.error(
- "Failed to recover table process {} (action {}) for table {},
marking it FAILED "
- + "and skipping so AMS can continue to start up.",
- processMeta.getProcessId(),
- scheduler.getAction(),
- tableRuntime.getTableIdentifier(),
- t);
- markRecoverFailed(store, t);
+ if
(getTableProcessInstances(tableRuntime.getTableIdentifier()).containsKey(processId))
{
+ LOG.debug(
+ "Table process {} for table {} is already tracked, skipping
duplicate recovery.",
+ processId,
+ tableRuntime.getTableIdentifier());
+ return;
+ }
+ DefaultTableProcessStore store =
+ new DefaultTableProcessStore(
+ processId,
+ tableRuntime,
+ processMeta,
+ scheduler.getAction(),
+ processMeta.getRetryNumber());
+ try {
+ TableProcess process = scheduler.recover(tableRuntime, store);
+ trackTableProcess(tableRuntime.getTableIdentifier(), store, process);
+ executeOrTraceProcess(store, process);
+ } catch (Throwable t) {
+ LOG.error(
+ "Failed to recover table process {} (action {}) for table {},
marking it FAILED "
+ + "and continuing recovery.",
+ processId,
+ scheduler.getAction(),
+ tableRuntime.getTableIdentifier(),
+ t);
+ markRecoverFailed(store, t);
+ }
+ } finally {
+ recoveringProcessIds.remove(processId);
}
}
/**
* Best-effort mark an un-recoverable process as {@link
ProcessStatus#FAILED} so it is not picked
- * up again on the next AMS restart. Never throws.
+ * up again by a later recovery attempt. Never throws.
*
* @param store process store
* @param cause the recovery failure
@@ -284,13 +313,13 @@ public class ProcessService extends PersistentBase {
ProcessStatus.FAILED,
ProcessEvent.COMPLETE_FAILED,
store.getExternalProcessIdentifier(),
- "Failed to recover process on AMS startup: " + cause.getMessage(),
+ "Failed to recover process: " + cause.getMessage(),
store.getProcessParameters(),
store.getSummary());
} catch (Throwable t) {
LOG.error(
- "Failed to mark un-recoverable table process {} as FAILED; it may be
retried on the "
- + "next AMS restart.",
+ "Failed to mark un-recoverable table process {} as FAILED; a later
recovery attempt "
+ + "may retry it.",
store.getProcessId(),
t);
}
@@ -635,6 +664,7 @@ public class ProcessService extends PersistentBase {
*/
@Override
protected void handleTableAdded(AmoroTable<?> table, TableRuntime
tableRuntime) {
+ recoverProcesses(Collections.singletonList(tableRuntime));
findSchedulersByFormat(tableRuntime.getFormat())
.forEach(s -> s.handleTableAdded(table, tableRuntime));
}
diff --git
a/amoro-ams/src/test/java/org/apache/amoro/server/TestDefaultProcessService.java
b/amoro-ams/src/test/java/org/apache/amoro/server/TestDefaultProcessService.java
index c49bfe348..0fbcea418 100644
---
a/amoro-ams/src/test/java/org/apache/amoro/server/TestDefaultProcessService.java
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/TestDefaultProcessService.java
@@ -21,14 +21,19 @@ package org.apache.amoro.server;
import org.apache.amoro.BasicTableTestHelper;
import org.apache.amoro.ServerTableIdentifier;
import org.apache.amoro.TableFormat;
+import org.apache.amoro.TableRuntime;
import org.apache.amoro.TableTestHelper;
import org.apache.amoro.catalog.BasicCatalogTestHelper;
import org.apache.amoro.catalog.CatalogTestHelper;
import org.apache.amoro.process.ProcessStatus;
+import org.apache.amoro.process.TableProcess;
import org.apache.amoro.process.TableProcessStore;
+import org.apache.amoro.server.persistence.PersistentBase;
+import org.apache.amoro.server.persistence.mapper.TableProcessMapper;
import org.apache.amoro.server.process.MockActionCoordinator;
import org.apache.amoro.server.process.MockExecuteEngine;
import org.apache.amoro.server.process.ProcessService;
+import org.apache.amoro.server.process.TableProcessMeta;
import org.apache.amoro.server.process.ThrowingRecoverActionCoordinator;
import org.apache.amoro.server.table.AMSTableTestBase;
import org.junit.After;
@@ -41,7 +46,13 @@ import org.junit.runners.Parameterized;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
/**
@@ -53,6 +64,7 @@ public class TestDefaultProcessService extends
AMSTableTestBase {
private static final long WAIT_TIMEOUT_MS = 60_000L;
private static final long POLL_INTERVAL_MS = 3_000L;
+ private static final Persistence PERSISTENCE = new Persistence();
/**
* Parameterization for catalog and table helpers.
@@ -177,7 +189,7 @@ public class TestDefaultProcessService extends
AMSTableTestBase {
ProcessService.TableProcessHolder holder =
getAnyActiveTableProcessHolder();
TableProcessStore store = holder.getStore();
- org.apache.amoro.TableRuntime tableRuntime =
holder.getProcess().getTableRuntime();
+ TableRuntime tableRuntime = holder.getProcess().getTableRuntime();
awaitEngineStatus(executeEngine, store.getExternalProcessIdentifier(),
ProcessStatus.RUNNING);
Assert.assertEquals(ProcessStatus.RUNNING, store.getStatus());
@@ -219,6 +231,121 @@ public class TestDefaultProcessService extends
AMSTableTestBase {
}
}
+ /** Verify active processes are recovered when table ownership moves to this
AMS. */
+ @Test(timeout = 60_000)
+ public void testRecoverTableProcessWhenTableAdded() {
+ MockExecuteEngine executeEngine = getExecuteEngine();
+ ExecutorService recoveryExecutor = Executors.newSingleThreadExecutor();
+ BlockingRecoverActionCoordinator coordinator =
+ new BlockingRecoverActionCoordinator(executeEngine);
+ try {
+ // Start a process as the old table owner and capture its persisted
identity.
+ createTable();
+ awaitActiveInstances(executeEngine);
+
+ ProcessService.TableProcessHolder originalHolder =
getAnyActiveTableProcessHolder();
+ TableProcessStore originalStore = originalHolder.getStore();
+ TableRuntime tableRuntime =
originalHolder.getProcess().getTableRuntime();
+ long processId = originalStore.getProcessId();
+ String originalExternalId = originalStore.getExternalProcessIdentifier();
+
+ // Simulate losing the old owner: stop its external process and wait
until the local active
+ // process entry has been removed.
+ executeEngine.tryCancelTableProcess(originalHolder.getProcess(),
originalExternalId);
+ awaitCondition(
+ () -> originalStore.getStatus() == ProcessStatus.CANCELED,
+ WAIT_TIMEOUT_MS,
+ POLL_INTERVAL_MS);
+ awaitCondition(
+ () ->
+ processServiceService()
+ .getTableProcessInstances(tableRuntime.getTableIdentifier())
+ .isEmpty(),
+ WAIT_TIMEOUT_MS,
+ POLL_INTERVAL_MS);
+ Assert.assertFalse(
+ processServiceService()
+ .getTableProcessInstances(tableRuntime.getTableIdentifier())
+ .containsValue(originalHolder));
+
+ // Recreate the database state observed after an abrupt owner loss. The
process remains
+ // RUNNING, but its external identifier is unavailable to the new owner.
+ markProcessRunningWithoutExternalIdentifier(processId);
+ processServiceService().unInstallAllActionCoordinators();
+ processServiceService().installActionCoordinator(coordinator);
+
+ // Hold the first table-added recovery inside the coordinator, then
deliver the same event
+ // again. The second event MUST see the atomic recovery reservation and
return without
+ // recovering the process a second time.
+ Future<?> firstRecovery =
+ recoveryExecutor.submit(
+ () ->
+ processServiceService()
+ .getTableHandlerChain()
+ .fireTableAdded(
+
tableService().loadTable(tableRuntime.getTableIdentifier()),
+ tableRuntime));
+ coordinator.awaitRecoveryStarted();
+
+ processServiceService()
+ .getTableHandlerChain()
+ .fireTableAdded(
+ tableService().loadTable(tableRuntime.getTableIdentifier()),
tableRuntime);
+ Assert.assertEquals(1, coordinator.getRecoveryCount());
+
+ coordinator.releaseRecovery();
+ firstRecovery.get(WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+
+ // The new owner MUST track the same persisted process ID with a newly
submitted external
+ // process. The old store must not remain in the active-process map.
+ awaitCondition(
+ () ->
+ processServiceService()
+ .getTableProcessInstances(tableRuntime.getTableIdentifier())
+ .containsKey(processId),
+ WAIT_TIMEOUT_MS,
+ POLL_INTERVAL_MS);
+
+ ProcessService.TableProcessHolder recoveredHolder =
+ processServiceService()
+ .getTableProcessInstances(tableRuntime.getTableIdentifier())
+ .get(processId);
+ awaitCondition(
+ () ->
+ recoveredHolder.getStore().getStatus() == ProcessStatus.RUNNING
+ &&
!recoveredHolder.getStore().getExternalProcessIdentifier().isEmpty(),
+ WAIT_TIMEOUT_MS,
+ POLL_INTERVAL_MS);
+
+ String recoveredExternalId =
recoveredHolder.getStore().getExternalProcessIdentifier();
+ Assert.assertNotEquals(originalExternalId, recoveredExternalId);
+ Assert.assertNotSame(originalStore, recoveredHolder.getStore());
+ Assert.assertEquals(
+ 1,
+ processServiceService()
+ .getTableProcessInstances(tableRuntime.getTableIdentifier())
+ .size());
+ Assert.assertEquals(1, executeEngine.getActiveInstances().size());
+
+ // handleTableAdded also starts the periodic scheduler. Wait until it
actually triggers and
+ // verify that the recovered RUNNING process prevents a second process
from being submitted.
+ coordinator.awaitSchedulerTriggered();
+ Assert.assertEquals(1, executeEngine.getActiveInstances().size());
+ Assert.assertEquals(
+ 1,
+ processServiceService()
+ .getTableProcessInstances(tableRuntime.getTableIdentifier())
+ .size());
+
+ dropTable();
+ } catch (Throwable t) {
+ throw new RuntimeException(t);
+ } finally {
+ coordinator.releaseRecovery();
+ recoveryExecutor.shutdownNow();
+ }
+ }
+
/**
* Verify that a single un-recoverable process record does not abort AMS
startup: {@code
* recoverProcesses} must not propagate the failure, the bad record is
skipped and persisted as
@@ -234,7 +361,7 @@ public class TestDefaultProcessService extends
AMSTableTestBase {
ProcessService.TableProcessHolder holder =
getAnyActiveTableProcessHolder();
TableProcessStore store = holder.getStore();
- org.apache.amoro.TableRuntime tableRuntime =
holder.getProcess().getTableRuntime();
+ TableRuntime tableRuntime = holder.getProcess().getTableRuntime();
awaitEngineStatus(executeEngine, store.getExternalProcessIdentifier(),
ProcessStatus.RUNNING);
Assert.assertEquals(ProcessStatus.RUNNING, store.getStatus());
@@ -329,6 +456,84 @@ public class TestDefaultProcessService extends
AMSTableTestBase {
return getAnyActiveTableProcessHolder().getStore();
}
+ private void markProcessRunningWithoutExternalIdentifier(long processId) {
+ PERSISTENCE.markProcessRunningWithoutExternalIdentifier(processId);
+ }
+
+ private static class Persistence extends PersistentBase {
+ private void markProcessRunningWithoutExternalIdentifier(long processId) {
+ doAs(
+ TableProcessMapper.class,
+ mapper -> {
+ TableProcessMeta meta = mapper.getProcessMeta(processId);
+ mapper.updateProcess(
+ meta.getTableId(),
+ processId,
+ "",
+ ProcessStatus.RUNNING,
+ meta.getProcessStage(),
+ meta.getRetryNumber(),
+ 0L,
+ "",
+ meta.getProcessParameters(),
+ meta.getSummary());
+ });
+ }
+ }
+
+ private static class BlockingRecoverActionCoordinator extends
MockActionCoordinator {
+ private final CountDownLatch recoveryStarted = new CountDownLatch(1);
+ private final CountDownLatch releaseRecovery = new CountDownLatch(1);
+ private final CountDownLatch schedulerTriggered = new CountDownLatch(1);
+ private final AtomicInteger recoveryCount = new AtomicInteger();
+
+ private BlockingRecoverActionCoordinator(MockExecuteEngine executeEngine) {
+ super(executeEngine);
+ }
+
+ @Override
+ public TableProcess recoverTableProcess(
+ TableRuntime tableRuntime, TableProcessStore processStore) {
+ recoveryCount.incrementAndGet();
+ recoveryStarted.countDown();
+ try {
+ if (!releaseRecovery.await(WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ throw new AssertionError("Timed out waiting to release process
recovery");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while waiting to recover
process", e);
+ }
+ return super.recoverTableProcess(tableRuntime, processStore);
+ }
+
+ @Override
+ public Optional<TableProcess> trigger(TableRuntime tableRuntime) {
+ schedulerTriggered.countDown();
+ return super.trigger(tableRuntime);
+ }
+
+ private void awaitRecoveryStarted() throws InterruptedException {
+ if (!recoveryStarted.await(WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ throw new AssertionError("Process recovery did not start");
+ }
+ }
+
+ private void releaseRecovery() {
+ releaseRecovery.countDown();
+ }
+
+ private void awaitSchedulerTriggered() throws InterruptedException {
+ if (!schedulerTriggered.await(WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ throw new AssertionError("Table scheduler did not trigger");
+ }
+ }
+
+ private int getRecoveryCount() {
+ return recoveryCount.get();
+ }
+ }
+
/** Wait until the given externalProcessIdentifier reaches the specified
status. */
private void awaitEngineStatus(MockExecuteEngine engine, String externalId,
ProcessStatus status)
throws InterruptedException {