diff --git a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportFileManager.java b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportFileManager.java
new file mode 100644
index 000000000000..4ada8c1e1f35
--- /dev/null
+++ b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportFileManager.java
@@ -0,0 +1,275 @@
+/*
+ * 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.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.channels.FileLock;
+import java.nio.channels.OverlappingFileLockException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import org.apache.commons.io.FileUtils;
+import org.apache.ratis.util.AtomicFileOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages on-disk paths and artifacts for container ID export jobs.
+ *
+ *
The export directory ({@code exportDirectory}, typically {@code {scm.db.dirs}/exports})
+ * uses the layout below. The manager gzip-compresses the archive ({@code .tar.gz}) so operators
+ * can stream entries with {@code zcat}.
+ *
+ *
While a job runs, shard text files are written under {@code export_{jobId}/}. The archive is
+ * created only after all shards are written. The export manager writes
+ * {@code container-ids-{scope}-{timestamp}.tar.gz.tmp} and atomically renames it to
+ * {@code .tar.gz} on close ({@link AtomicFileOutputStream}), so a partial {@code .tar.gz} is
+ * never visible. {@link #lock()} is used to exclude concurrent writers.
+ *
+ *
+ * {exportDirectory}/
+ * ├── in_use.lock
+ * ├── {jobId}.in-progress
+ * ├── container-ids-{scope}-{timestamp}.tar.gz
+ * ├── container-ids-{scope}-{timestamp}.tar.gz.tmp
+ * └── export_{jobId}/
+ * ├── container-ids-{scope}-{timestamp}-part001.txt
+ * └── ...
+ *
+ *
+ * When {@code export_{jobId}/} is deleted: the export manager deletes it after the
+ * archive is committed, or during {@link #cleanupFailedArtifacts} on failure or cancel.
+ * On startup, {@link #start()} deletes a leftover {@code export_{jobId}/} when no in-progress
+ * marker remains. If the marker still exists, {@link #start()} deletes {@code export_{jobId}/}
+ * together with the marker and any {@code .tar.gz.tmp} for that incomplete job.
+ *
+ *
When {@code .tar.gz.tmp} is deleted: only the temporary file is removed for
+ * incomplete work; a partial {@code .tar.gz} is never written. {@link #cleanupFailedArtifacts}
+ * deletes {@code .tar.gz.tmp} for failed or cancelled jobs. {@link #start()} deletes
+ * {@code .tar.gz.tmp} for jobs that still have an in-progress marker.
+ *
+ *
When completed {@code .tar.gz} is deleted: completed archives remain on disk until
+ * the export manager evicts them ({@code maxTerminalJobs} in {@code ContainerExportManager}).
+ *
+ *
SCM restart: in-memory job status is lost and {@code jobId} cannot be recovered from
+ * the archive file name. {@link #listCompletedArchivePaths()} returns existing {@code tarPath}
+ * values (oldest first) so {@code ContainerExportManager} can rebuild terminal-job eviction state.
+ * Jobs with an in-progress marker are treated as incomplete: {@link #start()} removes the marker,
+ * {@code export_{jobId}/}, and any {@code .tar.gz.tmp}, and the operator re-submits the export
+ * on the new leader.
+ */
+final class ExportFileManager {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ExportFileManager.class);
+
+ static final String IN_PROGRESS_MARKER_SUFFIX = ".in-progress";
+ static final String EXPORT_JOB_DIR_PREFIX = "export_";
+ static final String EXPORT_ARCHIVE_SUFFIX = ".tar.gz";
+ static final String EXPORT_ARCHIVE_TMP_SUFFIX = EXPORT_ARCHIVE_SUFFIX + AtomicFileOutputStream.TMP_EXTENSION;
+ static final String EXPORT_LOCK_NAME = "in_use.lock";
+
+ private final String exportDirectory;
+ private FileLock exportDirectoryLock;
+
+ ExportFileManager(String exportDirectory) {
+ this.exportDirectory = Objects.requireNonNull(exportDirectory, "exportDirectory == null");
+ }
+
+ String getExportDirectory() {
+ return exportDirectory;
+ }
+
+ void start() throws IOException {
+ Files.createDirectories(Paths.get(exportDirectory));
+ cleanupOrphanedExportArtifacts();
+ }
+
+ void lock() throws IOException {
+ if (exportDirectoryLock != null) {
+ return;
+ }
+ File lockFile = new File(exportDirectory, EXPORT_LOCK_NAME);
+ RandomAccessFile lockAccessFile = new RandomAccessFile(lockFile, "rws");
+ try {
+ FileLock lock = lockAccessFile.getChannel().tryLock();
+ if (lock == null) {
+ lockAccessFile.close();
+ throw new OverlappingFileLockException();
+ }
+ exportDirectoryLock = lock;
+ LOG.debug("Acquired container export directory lock {}", lockFile.getAbsolutePath());
+ } catch (OverlappingFileLockException | IOException e) {
+ lockAccessFile.close();
+ throw new IOException("Failed to lock container export directory " + exportDirectory, e);
+ }
+ }
+
+ void unlock() throws IOException {
+ if (exportDirectoryLock == null) {
+ return;
+ }
+ exportDirectoryLock.release();
+ exportDirectoryLock.channel().close();
+ exportDirectoryLock = null;
+ }
+
+ File resolveArchiveFile(ExportScope scope, String fileTimestamp) {
+ return new File(exportDirectory,
+ String.format("container-ids-%s-%s%s", scope.getValue(), fileTimestamp, EXPORT_ARCHIVE_SUFFIX));
+ }
+
+ File resolveArchiveTempFile(ExportScope scope, String fileTimestamp) {
+ return AtomicFileOutputStream.getTemporaryFile(resolveArchiveFile(scope, fileTimestamp));
+ }
+
+ /**
+ * Returns completed archive paths ({@code tarPath} in {@code ExportJob.Status}), oldest first.
+ */
+ List listCompletedArchivePaths() {
+ File exportDir = new File(exportDirectory);
+ File[] matches = exportDir.listFiles((dir, fileName) -> fileName.endsWith(EXPORT_ARCHIVE_SUFFIX)
+ && !fileName.endsWith(EXPORT_ARCHIVE_TMP_SUFFIX));
+ if (matches == null || matches.length == 0) {
+ return Collections.emptyList();
+ }
+ Arrays.sort(matches, Comparator.comparingLong(File::lastModified));
+ List archivePaths = new ArrayList<>(matches.length);
+ for (File archive : matches) {
+ archivePaths.add(archive.getAbsolutePath());
+ }
+ return archivePaths;
+ }
+
+ void markExportInProgress(String jobId) throws IOException {
+ Files.createFile(inProgressMarkerFile(jobId).toPath());
+ }
+
+ void clearExportInProgress(String jobId) {
+ FileUtils.deleteQuietly(inProgressMarkerFile(jobId));
+ }
+
+ void deleteExportTar(String tarPath) {
+ if (tarPath == null) {
+ return;
+ }
+ File archive = new File(tarPath);
+ if (archive.isFile() && FileUtils.deleteQuietly(archive)) {
+ LOG.debug("Removed container export archive: {}", archive.getName());
+ }
+ FileUtils.deleteQuietly(AtomicFileOutputStream.getTemporaryFile(archive));
+ }
+
+ void cleanupFailedArtifacts(Path jobDir, File archiveFile, String jobId) {
+ if (jobDir != null) {
+ FileUtils.deleteQuietly(jobDir.toFile());
+ }
+ if (archiveFile != null) {
+ FileUtils.deleteQuietly(AtomicFileOutputStream.getTemporaryFile(archiveFile));
+ FileUtils.deleteQuietly(archiveFile);
+ }
+ clearExportInProgress(jobId);
+ }
+
+ private void cleanupOrphanedExportArtifacts() {
+ File exportDir = new File(exportDirectory);
+ File[] children = exportDir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isFile() && child.getName().endsWith(IN_PROGRESS_MARKER_SUFFIX)) {
+ String jobId = child.getName().substring(
+ 0, child.getName().length() - IN_PROGRESS_MARKER_SUFFIX.length());
+ if (isUuidDirectoryName(jobId)) {
+ removeIncompleteExportArtifacts(jobId);
+ }
+ }
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ String jobId = jobIdFromExportDirName(child.getName());
+ if (jobId == null) {
+ continue;
+ }
+ if (inProgressMarkerFile(jobId).exists()) {
+ removeIncompleteExportArtifacts(jobId);
+ } else {
+ FileUtils.deleteQuietly(child);
+ }
+ }
+ }
+ deleteOrphanArchiveTempFiles();
+ }
+
+ private void removeIncompleteExportArtifacts(String jobId) {
+ LOG.info("Removing incomplete container export artifacts for job {}", jobId);
+ FileUtils.deleteQuietly(inProgressMarkerFile(jobId));
+ deleteOrphanArchiveTempFiles();
+ File jobDir = new File(exportDirectory, exportJobDirName(jobId));
+ if (jobDir.isDirectory()) {
+ FileUtils.deleteQuietly(jobDir);
+ LOG.debug("Removed orphaned container export job directory: {}", jobDir.getAbsolutePath());
+ }
+ }
+
+ private void deleteOrphanArchiveTempFiles() {
+ File exportDir = new File(exportDirectory);
+ File[] tempFiles = exportDir.listFiles((dir, fileName) -> fileName.endsWith(EXPORT_ARCHIVE_TMP_SUFFIX));
+ if (tempFiles == null) {
+ return;
+ }
+ for (File tempFile : tempFiles) {
+ if (FileUtils.deleteQuietly(tempFile)) {
+ LOG.debug("Removed incomplete container export archive temp file: {}", tempFile.getName());
+ }
+ }
+ }
+
+ private File inProgressMarkerFile(String jobId) {
+ return new File(exportDirectory, jobId + IN_PROGRESS_MARKER_SUFFIX);
+ }
+
+ static String exportJobDirName(String jobId) {
+ return EXPORT_JOB_DIR_PREFIX + jobId;
+ }
+
+ private static String jobIdFromExportDirName(String dirName) {
+ if (!dirName.startsWith(EXPORT_JOB_DIR_PREFIX)) {
+ return null;
+ }
+ String jobId = dirName.substring(EXPORT_JOB_DIR_PREFIX.length());
+ return isUuidDirectoryName(jobId) ? jobId : null;
+ }
+
+ private static boolean isUuidDirectoryName(String directoryName) {
+ try {
+ return directoryName.equals(UUID.fromString(directoryName).toString());
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+}
diff --git a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportScope.java b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportScope.java
new file mode 100644
index 000000000000..6cdee3992416
--- /dev/null
+++ b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportScope.java
@@ -0,0 +1,74 @@
+/*
+ * 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 org.apache.hadoop.hdds.protocol.proto.HddsProtos.LifeCycleState;
+import org.apache.hadoop.hdds.scm.container.ContainerHealthState;
+
+/**
+ * Container listing filters for an export job.
+ * An export job filters containers by {@link ContainerHealthState}, {@link LifeCycleState} or both.
+ * Example archive name:
+ * {@code container-ids-health-MISSING_lifecycle-OPEN-20260101T120000Z.tar.gz}
+ */
+public final class ExportScope {
+
+ private final LifeCycleState lifeCycleState;
+ private final ContainerHealthState healthState;
+ private final String value;
+
+ private ExportScope(LifeCycleState lifeCycleState, ContainerHealthState healthState, String value) {
+ this.lifeCycleState = lifeCycleState;
+ this.healthState = healthState;
+ this.value = value;
+ }
+
+ public static ExportScope of(LifeCycleState lifeCycleState, ContainerHealthState healthState) {
+ StringBuilder sb = new StringBuilder();
+ if (healthState != null) {
+ sb.append("health-").append(healthState.name());
+ }
+ if (lifeCycleState != null) {
+ if (sb.length() > 0) {
+ sb.append('_');
+ }
+ sb.append("lifecycle-").append(lifeCycleState.name());
+ }
+ return new ExportScope(lifeCycleState, healthState, sb.toString());
+ }
+
+ public LifeCycleState getLifeCycleState() {
+ return lifeCycleState;
+ }
+
+ public ContainerHealthState getHealthState() {
+ return healthState;
+ }
+
+ /**
+ * Stable filter name segment used in export TAR and shard file names.
+ */
+ public String getValue() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return value;
+ }
+}
diff --git a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/package-info.java b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/package-info.java
new file mode 100644
index 000000000000..103c9519fcab
--- /dev/null
+++ b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+/**
+ * This package contains classes related to container export.
+ */
+package org.apache.hadoop.hdds.scm.container.export;
diff --git a/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestExportFileManager.java b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestExportFileManager.java
new file mode 100644
index 000000000000..3dedd6342c58
--- /dev/null
+++ b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestExportFileManager.java
@@ -0,0 +1,125 @@
+/*
+ * 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.assertTrue;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.UUID;
+import org.apache.hadoop.hdds.scm.container.ContainerHealthState;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests for {@link ExportFileManager}.
+ */
+public class TestExportFileManager {
+
+ @TempDir
+ private File tempDir;
+
+ private ExportFileManager fileManager;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ fileManager = new ExportFileManager(tempDir.getAbsolutePath());
+ fileManager.start();
+ }
+
+ @Test
+ public void testResolveArchiveFile() {
+ ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
+ File archive = fileManager.resolveArchiveFile(scope, "20260101T120000Z");
+ assertTrue(archive.getName().endsWith(ExportFileManager.EXPORT_ARCHIVE_SUFFIX));
+ }
+
+ @Test
+ public void testResolveArchiveTempFile() {
+ ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
+ File tempFile = fileManager.resolveArchiveTempFile(scope, "20260101T120000Z");
+ assertTrue(tempFile.getName().endsWith(ExportFileManager.EXPORT_ARCHIVE_TMP_SUFFIX));
+ }
+
+ @Test
+ public void testListCompletedArchivePaths() throws Exception {
+ ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
+ File olderArchive = fileManager.resolveArchiveFile(scope, "20260101T120000Z");
+ assertTrue(olderArchive.createNewFile());
+ assertTrue(olderArchive.setLastModified(1_000L));
+ File newerArchive = fileManager.resolveArchiveFile(scope, "20260101T120001Z");
+ assertTrue(newerArchive.createNewFile());
+ assertTrue(newerArchive.setLastModified(2_000L));
+ File tempArchive = fileManager.resolveArchiveTempFile(scope, "20260101T120002Z");
+ assertTrue(tempArchive.createNewFile());
+
+ List completedPaths = fileManager.listCompletedArchivePaths();
+ assertEquals(2, completedPaths.size());
+ assertEquals(olderArchive.getAbsolutePath(), completedPaths.get(0));
+ assertEquals(newerArchive.getAbsolutePath(), completedPaths.get(1));
+ }
+
+ @Test
+ public void testOrphanJobDirRemovedOnStartup() throws Exception {
+ String jobId = UUID.randomUUID().toString();
+ Path orphanJobDir = tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId));
+ Files.createDirectories(orphanJobDir);
+
+ fileManager.start();
+
+ assertFalse(Files.exists(orphanJobDir));
+ }
+
+ @Test
+ public void testIncompleteExportArtifactsRemovedOnStartup() throws Exception {
+ String jobId = UUID.randomUUID().toString();
+ Path jobDir = tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId));
+ Files.createDirectories(jobDir);
+ ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
+ File partialArchiveTemp = fileManager.resolveArchiveTempFile(scope, "20260101T000000Z");
+ assertTrue(partialArchiveTemp.createNewFile());
+ File inProgress = new File(tempDir, jobId + ExportFileManager.IN_PROGRESS_MARKER_SUFFIX);
+ assertTrue(inProgress.createNewFile());
+
+ fileManager.start();
+
+ assertFalse(Files.exists(jobDir));
+ assertFalse(partialArchiveTemp.exists());
+ assertFalse(inProgress.exists());
+ }
+
+ @Test
+ public void testOrphanJobDirWithoutMarkerDoesNotDeleteCompletedTar() throws Exception {
+ String jobId = UUID.randomUUID().toString();
+ ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
+ File completedArchive = fileManager.resolveArchiveFile(scope, "20260101T000000Z");
+ assertTrue(completedArchive.createNewFile());
+ Path orphanJobDir = tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId));
+ Files.createDirectories(orphanJobDir);
+
+ fileManager.start();
+
+ assertTrue(completedArchive.exists());
+ assertFalse(Files.exists(orphanJobDir));
+ }
+}