diff --git a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ContainerExportManager.java b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ContainerExportManager.java new file mode 100644 index 00000000000..473d08cfa09 --- /dev/null +++ b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ContainerExportManager.java @@ -0,0 +1,276 @@ +/* + * 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.hadoop.hdds.scm.container.export; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BooleanSupplier; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.hdds.protocol.proto.HddsProtos.LifeCycleState; +import org.apache.hadoop.hdds.scm.container.ContainerHealthState; +import org.apache.hadoop.hdds.scm.container.ContainerID; +import org.apache.hadoop.hdds.scm.container.ContainerManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages asynchronous container ID export jobs on the SCM leader. + * + *
Health filters read {@link org.apache.hadoop.hdds.scm.container.ContainerInfo#getHealthState()} + * as last written by Replication Manager; they are not recomputed during export and may be stale + * if RM has not yet evaluated a container. + * + *
Job status is kept in memory only. On SCM restart or leader failover, in-flight jobs are lost + * and the operator must re-submit on the new leader. {@link ExportFileManager} owns on-disk layout, + * locking, part files, and completed archives; this class tracks {@link ExportJob} state and + * schedules work. + * + *
Job submission and the running-job slot are guarded by a {@link ReadWriteLock}. Per-job
+ * progress is read via {@link ExportJob#toStatus()}, which uses its own read lock for a consistent
+ * snapshot.
+ */
+public class ContainerExportManager {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ContainerExportManager.class);
+
+ private static final int DEFAULT_BATCH_SIZE = 100_000;
+ private static final int DEFAULT_PART_SIZE = 500_000;
+ private static final long SHUTDOWN_TIMEOUT_MS = 5_000;
+
+ private final ReadWriteLock lock = new ReentrantReadWriteLock();
+ private final Map Mutable fields are guarded by a {@link ReadWriteLock} so {@link #toStatus()} returns a
+ * consistent snapshot while the worker updates progress.
*/
public final class ExportJob {
+ private final ReadWriteLock lock = new ReentrantReadWriteLock();
+
private final Id id;
private final ExportScope scope;
private final String jobStartTime;
+ private final ContainerID startContainerId;
+ private final int batchSize;
+ private final int partSize;
+ // Planned .tar.gz output path, fixed at job creation; used by the worker to write the archive.
+ private final String plannedArchivePath;
+ private ExecutionState executionState = ExecutionState.RUNNING;
+ private long totalRows;
+ private String errorMessage;
/**
* Unique job identifier.
@@ -75,10 +92,158 @@ public int hashCode() {
}
}
- ExportJob(Id id, ExportScope scope, String jobStartTime) {
+ /**
+ * Immutable snapshot of export progress.
+ */
+ public static final class Status {
+ private final Id id;
+ private final ExecutionState executionState;
+ private final long totalRows;
+ // plannedArchivePath when the job SUCCEEDED with rows; null while running, on failure, or zero matches.
+ private final String completedArchivePath;
+ private final String errorMessage;
+
+ private Status(Id id, ExecutionState executionState, long totalRows, String completedArchivePath,
+ String errorMessage) {
+ this.id = id;
+ this.executionState = executionState;
+ this.totalRows = totalRows;
+ this.completedArchivePath = completedArchivePath;
+ this.errorMessage = errorMessage;
+ }
+
+ public Id getId() {
+ return id;
+ }
+
+ public ExecutionState getExecutionState() {
+ return executionState;
+ }
+
+ public long getTotalRows() {
+ return totalRows;
+ }
+
+ public String getCompletedArchivePath() {
+ return completedArchivePath;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+ }
+
+ /**
+ * Job execution state.
+ */
+ public enum ExecutionState {
+ RUNNING(false),
+ SUCCEEDED(true),
+ FAILED(true);
+
+ private final boolean terminal;
+
+ ExecutionState(boolean terminal) {
+ this.terminal = terminal;
+ }
+
+ public boolean isTerminal() {
+ return terminal;
+ }
+ }
+
+ ExportJob(Id id, ExportScope scope, String jobStartTime, String plannedArchivePath, ContainerID startContainerId,
+ int batchSize, int partSize) {
this.id = id;
this.scope = scope;
this.jobStartTime = jobStartTime;
+ this.plannedArchivePath = plannedArchivePath;
+ this.startContainerId = startContainerId != null ? startContainerId : ContainerID.valueOf(0);
+ this.batchSize = batchSize;
+ this.partSize = partSize;
+ }
+
+ Id getId() {
+ return id;
+ }
+
+ LifeCycleState getLifeCycleState() {
+ return scope.getLifeCycleState();
+ }
+
+ ContainerHealthState getHealthState() {
+ return scope.getHealthState();
+ }
+
+ String getPlannedArchivePath() {
+ return plannedArchivePath;
+ }
+
+ ContainerID getStartContainerId() {
+ return startContainerId;
+ }
+
+ int getBatchSize() {
+ return batchSize;
+ }
+
+ int getPartSize() {
+ return partSize;
+ }
+
+ void updateTotalRows(long rows) {
+ lock.writeLock().lock();
+ try {
+ totalRows = rows;
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ void completeWithNoMatches() {
+ lock.writeLock().lock();
+ try {
+ transitionToTerminal(ExecutionState.SUCCEEDED);
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ void completeSucceeded() {
+ lock.writeLock().lock();
+ try {
+ transitionToTerminal(ExecutionState.SUCCEEDED);
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ void fail(String message) {
+ lock.writeLock().lock();
+ try {
+ errorMessage = message;
+ transitionToTerminal(ExecutionState.FAILED);
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ Status toStatus() {
+ lock.readLock().lock();
+ try {
+ String completedArchivePath =
+ executionState == ExecutionState.SUCCEEDED && totalRows > 0 ? plannedArchivePath : null;
+ return new Status(id, executionState, totalRows, completedArchivePath, errorMessage);
+ } finally {
+ lock.readLock().unlock();
+ }
+ }
+
+ private void transitionToTerminal(ExecutionState terminalState) {
+ if (executionState.isTerminal()) {
+ throw new IllegalStateException("Export job " + id + " is already terminal: " + executionState);
+ }
+ executionState = terminalState;
}
String partFileName(int partIndex) {
diff --git a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java
index 745bea6ee1d..3c5f4458dd1 100644
--- a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java
+++ b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java
@@ -90,6 +90,7 @@
import org.apache.hadoop.hdds.scm.container.IncrementalContainerReportHandler;
import org.apache.hadoop.hdds.scm.container.balancer.ContainerBalancer;
import org.apache.hadoop.hdds.scm.container.balancer.MoveManager;
+import org.apache.hadoop.hdds.scm.container.export.ContainerExportManager;
import org.apache.hadoop.hdds.scm.container.placement.algorithms.ContainerPlacementPolicyFactory;
import org.apache.hadoop.hdds.scm.container.placement.algorithms.SCMContainerPlacementMetrics;
import org.apache.hadoop.hdds.scm.container.placement.metrics.SCMMetrics;
@@ -312,6 +313,7 @@ public final class StorageContainerManager extends ServiceRuntimeInfoImpl
private final ContainerBalancer containerBalancer;
// MoveManager is used by ContainerBalancer to schedule container moves
private final MoveManager moveManager;
+ private final ContainerExportManager containerExportManager;
private StatefulServiceStateManager statefulServiceStateManager;
// Used to keep track of pending replication and pending deletes for
// container replicas.
@@ -436,6 +438,9 @@ private StorageContainerManager(OzoneConfiguration conf,
initializeSystemManagers(conf, configurator);
+ containerExportManager = new ContainerExportManager(
+ getScmId(), containerManager, this::checkLeader, conf);
+
if (isSecretKeyEnable(securityConfig)) {
secretKeyManagerService = new SecretKeyManagerService(scmContext, conf,
scmHAManager.getRatisServer());
@@ -1607,6 +1612,7 @@ public void start() throws IOException {
scmBlockManager.start();
leaseManager.start();
+ containerExportManager.start();
try {
httpServer = new StorageContainerManagerHttpServer(configuration, this);
@@ -1735,6 +1741,13 @@ public void stop() {
LOG.error("SCM block manager service stop failed.", ex);
}
+ try {
+ LOG.info("Shutting down Container Export Manager.");
+ containerExportManager.shutdown();
+ } catch (Exception ex) {
+ LOG.error("Container Export Manager shutdown failed.", ex);
+ }
+
if (metrics != null) {
metrics.unRegister();
}
@@ -1948,6 +1961,10 @@ public MoveManager getMoveManager() {
return moveManager;
}
+ public ContainerExportManager getContainerExportManager() {
+ return containerExportManager;
+ }
+
/**
* Returns SCM root CA rotation manager.
*/
diff --git a/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestContainerExportManager.java b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestContainerExportManager.java
new file mode 100644
index 00000000000..5e38728b036
--- /dev/null
+++ b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestContainerExportManager.java
@@ -0,0 +1,376 @@
+/*
+ * 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.hadoop.hdds.scm.container.export;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.File;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
+import java.util.stream.Collectors;
+import java.util.zip.GZIPInputStream;
+import org.apache.commons.compress.archivers.ArchiveInputStream;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.io.FileUtils;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.LifeCycleState;
+import org.apache.hadoop.hdds.scm.container.ContainerHealthState;
+import org.apache.hadoop.hdds.scm.container.ContainerID;
+import org.apache.hadoop.hdds.scm.container.ContainerManager;
+import org.apache.hadoop.hdds.utils.Archiver;
+import org.apache.ozone.test.GenericTestUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests for {@link ContainerExportManager}.
+ */
+public class TestContainerExportManager {
+
+ private static final int TEST_BATCH_SIZE = 2;
+ private static final int TEST_PART_SIZE = 3;
+ private static final String TEST_SCM_ID = "test-scm";
+
+ @TempDir
+ private File tempDir;
+
+ private ContainerManager containerManager;
+ private ContainerExportManager exportManager;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ containerManager = mock(ContainerManager.class);
+ exportManager = newExportManager(TEST_PART_SIZE, TEST_BATCH_SIZE, () -> true);
+ }
+
+ @AfterEach
+ public void teardown() {
+ if (exportManager != null) {
+ exportManager.shutdown();
+ }
+ }
+
+ @Test
+ public void testRejectMissingFilters() {
+ assertThrows(IllegalArgumentException.class, () ->
+ exportManager.submitJob(ContainerID.valueOf(0), null, null));
+ }
+
+ @Test
+ public void testRejectSubmitWhenNotLeader() throws Exception {
+ exportManager.shutdown();
+ exportManager = newExportManager(TEST_PART_SIZE, TEST_BATCH_SIZE, () -> false);
+ assertNull(exportManager.submitJob(ContainerID.valueOf(0), null, ContainerHealthState.MISSING));
+ }
+
+ @Test
+ public void testGetExportStatusUnknownJobReturnsNull() {
+ assertNull(exportManager.getExportStatus(ExportJob.Id.newId()));
+ }
+
+ @Test
+ public void testRejectConcurrentExport() {
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(0)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(ids(1));
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(2)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenAnswer(invocation -> {
+ Thread.sleep(60_000);
+ return Collections.emptyList();
+ });
+
+ ExportJob.Id first = exportManager.submitJob(ContainerID.valueOf(0), null,
+ ContainerHealthState.MISSING);
+ assertNotNull(first);
+ assertNull(exportManager.submitJob(ContainerID.valueOf(0), null, ContainerHealthState.EMPTY));
+ }
+
+ @Test
+ public void testNullStartDefaultsToZeroCursor() throws Exception {
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(0)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(Collections.emptyList());
+
+ ExportJob.Id jobId = exportManager.submitJob(null, null, ContainerHealthState.MISSING);
+ assertNotNull(jobId);
+ waitForTerminal(jobId);
+
+ verify(containerManager).getContainerIDs(
+ eq(ContainerID.valueOf(0)), anyInt(), isNull(), eq(ContainerHealthState.MISSING));
+ }
+
+ @Test
+ public void testExportStartsFromRequestedCursor() throws Exception {
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(5)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(ids(5, 6));
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(7)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(Collections.emptyList());
+
+ ExportJob.Id jobId = exportManager.submitJob(ContainerID.valueOf(5), null,
+ ContainerHealthState.MISSING);
+ assertNotNull(jobId);
+
+ ExportJob.Status status = waitForTerminal(jobId);
+ assertEquals(ExportJob.ExecutionState.SUCCEEDED, status.getExecutionState());
+ assertEquals(2, status.getTotalRows());
+
+ verify(containerManager).getContainerIDs(
+ eq(ContainerID.valueOf(5)), anyInt(), isNull(), eq(ContainerHealthState.MISSING));
+ }
+
+ @Test
+ public void testExportWithLifeCycleFilter() throws Exception {
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(0)), anyInt(), eq(LifeCycleState.OPEN), isNull()))
+ .thenReturn(ids(1));
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(2)), anyInt(), eq(LifeCycleState.OPEN), isNull()))
+ .thenReturn(Collections.emptyList());
+
+ ExportJob.Id jobId = exportManager.submitJob(ContainerID.valueOf(0), LifeCycleState.OPEN, null);
+ assertNotNull(jobId);
+ waitForTerminal(jobId);
+
+ verify(containerManager).getContainerIDs(
+ eq(ContainerID.valueOf(0)), anyInt(), eq(LifeCycleState.OPEN), isNull());
+ }
+
+ @Test
+ public void testZeroMatchesSucceedsWithoutArchive() throws Exception {
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(0)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(Collections.emptyList());
+
+ ExportJob.Id jobId = exportManager.submitJob(ContainerID.valueOf(0), null,
+ ContainerHealthState.MISSING);
+ assertNotNull(jobId);
+
+ ExportJob.Status status = waitForTerminal(jobId);
+ assertEquals(ExportJob.ExecutionState.SUCCEEDED, status.getExecutionState());
+ assertEquals(0, status.getTotalRows());
+ assertNull(status.getCompletedArchivePath());
+ }
+
+ @Test
+ public void testSinglePartExportCreatesTar() throws Exception {
+ exportManager.shutdown();
+ exportManager = newExportManager(100, TEST_BATCH_SIZE, () -> true);
+
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(0)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(ids(1, 2, 3, 4));
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(5)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(Collections.emptyList());
+
+ ExportJob.Id jobId = exportManager.submitJob(ContainerID.valueOf(0), null,
+ ContainerHealthState.MISSING);
+ assertNotNull(jobId);
+
+ ExportJob.Status status = waitForTerminal(jobId);
+ if (status.getExecutionState() == ExportJob.ExecutionState.FAILED) {
+ fail(status.getErrorMessage());
+ }
+ assertEquals(ExportJob.ExecutionState.SUCCEEDED, status.getExecutionState());
+ assertTrue(status.getCompletedArchivePath().endsWith(ExportFileManager.EXPORT_ARCHIVE_SUFFIX));
+ assertTrue(new File(status.getCompletedArchivePath()).exists());
+ }
+
+ @Test
+ public void testMultiPartExportCreatesTar() throws Exception {
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(0)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(ids(1, 2));
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(3)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(ids(3, 4));
+ when(containerManager.getContainerIDs(
+ eq(ContainerID.valueOf(5)), anyInt(), isNull(), eq(ContainerHealthState.MISSING)))
+ .thenReturn(Collections.emptyList());
+
+ ExportJob.Id jobId = exportManager.submitJob(ContainerID.valueOf(0), null,
+ ContainerHealthState.MISSING);
+ assertNotNull(jobId);
+
+ ExportJob.Status status = waitForTerminal(jobId);
+ if (status.getExecutionState() == ExportJob.ExecutionState.FAILED) {
+ fail(status.getErrorMessage());
+ }
+ assertEquals(ExportJob.ExecutionState.SUCCEEDED, status.getExecutionState());
+ assertEquals(4, status.getTotalRows());
+ assertNotNull(status.getCompletedArchivePath());
+ assertTrue(status.getCompletedArchivePath().endsWith(ExportFileManager.EXPORT_ARCHIVE_SUFFIX));
+ File archive = new File(status.getCompletedArchivePath());
+ assertTrue(archive.exists());
+ assertFalse(Files.exists(tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId))));
+
+ Path extractDir = Files.createTempDirectory("export-archive");
+ try {
+ extractGzTar(archive, extractDir);
+ String part2Name;
+ try (java.util.stream.Stream