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

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


The following commit(s) were added to refs/heads/master by this push:
     new 734435aa846 HDDS-15839. Implement SCM container ID export manager 
(#11097).
734435aa846 is described below

commit 734435aa84632f12ff5cf6c2aeced1c6613fb46a
Author: Sarveksha Yeshavantha Raju 
<[email protected]>
AuthorDate: Tue Oct 13 11:40:16 2026 +0530

    HDDS-15839. Implement SCM container ID export manager (#11097).
---
 .../container/export/ContainerExportManager.java   | 299 ++++++++++++
 .../scm/container/export/ExportFileManager.java    | 124 +++--
 .../hdds/scm/container/export/ExportJob.java       | 145 +++++-
 .../hdds/scm/container/export/ExportScope.java     |   7 -
 .../hdds/scm/server/StorageContainerManager.java   |  20 +
 .../export/TestContainerExportManager.java         | 525 +++++++++++++++++++++
 .../container/export/TestExportFileManager.java    | 231 ---------
 .../hdds/scm/container/export/TestExportJob.java   |  67 ---
 8 files changed, 1018 insertions(+), 400 deletions(-)

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..3cfd1d25ce6
--- /dev/null
+++ 
b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ContainerExportManager.java
@@ -0,0 +1,299 @@
+/*
+ * 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.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+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.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+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.apache.hadoop.hdds.scm.exceptions.SCMException;
+import org.apache.ratis.util.Preconditions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages asynchronous container ID export jobs on the SCM leader.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ */
+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 Map<ExportJob.Id, ExportJob> jobMap = new 
ConcurrentHashMap<>();
+  private final AtomicReference<ExportJob> runningJob = new 
AtomicReference<>();
+  private final ExecutorService workerPool;
+  private final ContainerManager containerManager;
+  private final ExportFileManager fileManager;
+  private final BooleanSupplier isLeaderReady;
+  private final int partSize;
+  private final int batchSize;
+
+  public ContainerExportManager(String scmId, ContainerManager 
containerManager, BooleanSupplier isLeaderReady,
+      OzoneConfiguration conf) {
+    this(scmId, containerManager, isLeaderReady,
+        ExportFileManager.resolveExportDirectory(conf), DEFAULT_PART_SIZE, 
DEFAULT_BATCH_SIZE);
+  }
+
+  ContainerExportManager(String scmId, ContainerManager containerManager, 
BooleanSupplier isLeaderReady,
+      String exportDirectory, int partSize, int batchSize) {
+    if (partSize <= 0 || batchSize <= 0) {
+      throw new IllegalArgumentException("partSize and batchSize must be 
positive: partSize=" + partSize
+          + ", batchSize=" + batchSize);
+    }
+    this.containerManager = Objects.requireNonNull(containerManager, 
"containerManager == null");
+    this.isLeaderReady = Objects.requireNonNull(isLeaderReady, "isLeaderReady 
== null");
+    this.fileManager = new ExportFileManager(exportDirectory);
+    this.partSize = partSize;
+    this.batchSize = batchSize;
+    this.workerPool = newWorkerPool(scmId);
+  }
+
+  private ExecutorService newWorkerPool(String scmId) {
+    return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new 
LinkedBlockingQueue<>(), r -> {
+      Thread t = new Thread(r, scmId + "-ContainerExportWorker");
+      t.setDaemon(true);
+      return t;
+    }) {
+      @Override
+      protected void terminated() {
+        onWorkerTerminated();
+      }
+    };
+  }
+
+  /**
+   * Initializes the export directory. Must be called once before submitting 
jobs.
+   */
+  public void start() throws IOException {
+    fileManager.start();
+    LOG.info("ContainerExportManager started (dir={}, partSize={}, 
batchSize={})",
+        fileManager.getExportDirectory(), partSize, batchSize);
+  }
+
+  /**
+   * Submit a container ID export job on the SCM leader.
+   *
+   * @return the submitted job
+   * @throws SCMException with {@link SCMException.ResultCodes#SCM_NOT_LEADER} 
if this SCM is not the ready leader
+   * @throws IOException if another export is already running or the export 
worker is shut down
+   */
+  public ExportJob submitJob(ContainerID start, LifeCycleState lifeCycleState, 
ContainerHealthState healthState)
+      throws IOException {
+    if (!isLeaderReady.getAsBoolean()) {
+      throw new SCMException("Container ID export can only be submitted on the 
SCM leader",
+          SCMException.ResultCodes.SCM_NOT_LEADER);
+    }
+
+    final ExportScope scope = ExportScope.of(lifeCycleState, healthState);
+    final ExportJob job = new ExportJob(scope, start, batchSize, partSize);
+    if (!runningJob.compareAndSet(null, job)) {
+      throw new IOException("Another container ID export is already running");
+    }
+
+    jobMap.put(job.getId(), job);
+    try {
+      workerPool.submit(() -> executeExport(job));
+    } catch (RejectedExecutionException e) {
+      jobMap.remove(job.getId());
+      runningJob.compareAndSet(job, null);
+      throw new IOException("Container ID export worker is shut down", e);
+    }
+    LOG.info("Submitted container ID export job {} (scope={}, start={}, 
batchSize={}, partSize={})",
+        job, scope, start, batchSize, partSize);
+    return job;
+  }
+
+  public ExportJob.Status getExportStatus(ExportJob.Id jobId) {
+    ExportJob job = jobMap.get(jobId);
+    return job != null ? job.getStatus() : null;
+  }
+
+  public void shutdown() {
+    LOG.info("Shutting down ContainerExportManager");
+    workerPool.shutdownNow();
+    try {
+      if (!workerPool.awaitTermination(SHUTDOWN_TIMEOUT_MS, 
TimeUnit.MILLISECONDS)) {
+        LOG.warn("Export worker did not stop within {} ms; the container 
export directory lock is "
+            + "released once it stops", SHUTDOWN_TIMEOUT_MS);
+      }
+    } catch (InterruptedException e) {
+      LOG.warn("Interrupted waiting for export worker shutdown; the container 
export directory lock is "
+          + "released once it stops");
+      Thread.currentThread().interrupt();
+    }
+  }
+
+  /**
+   * Runs once the worker has stopped, so the export directory is no longer in 
use: the lock is kept
+   * until then so that a new manager cannot start and delete files the worker 
is still writing.
+   */
+  private void onWorkerTerminated() {
+    // A job accepted but drained from the executor queue by shutdownNow() 
never ran; fail it.
+    final ExportJob notStarted = runningJob.getAndSet(null);
+    if (notStarted != null) {
+      notStarted.getFuture().completeExceptionally(new 
InterruptedException("Export cancelled: SCM shutting down"));
+    }
+    try {
+      fileManager.unlock();
+    } catch (IOException e) {
+      LOG.warn("Failed to unlock container export directory", e);
+    }
+  }
+
+  private void executeExport(ExportJob job) {
+    Long idsWritten = null;
+    Throwable failure = new IllegalStateException(job + " terminated 
unexpectedly");
+    try {
+      fileManager.createJobDirectory(job.getId());
+
+      idsWritten = writeContainerIDs(job);
+      if (idsWritten == null) {
+        failure = new InterruptedException("Export cancelled");
+        LOG.info("Export {} was cancelled", job);
+      } else {
+        if (idsWritten > 0) {
+          fileManager.writeArchive(job, isLeaderReady);
+          LOG.info("{} completed: {} ids, archive={}", job, idsWritten, 
job.getFileName());
+        } else {
+          LOG.info("{} completed with {} matching containers", job, 
idsWritten);
+        }
+        fileManager.deleteJobDirectory(job.getId());
+        failure = null;
+      }
+    } catch (Exception e) {
+      failure = e;
+      LOG.error("Export {} failed", job, e);
+    } finally {
+      if (failure != null) {
+        fileManager.cleanupFailedJob(job);
+      }
+      final boolean cleared = runningJob.compareAndSet(job, null);
+      if (failure == null) {
+        job.getFuture().complete(idsWritten);
+      } else {
+        job.getFuture().completeExceptionally(failure);
+      }
+      Preconditions.assertTrue(cleared);
+    }
+  }
+
+  private Long writeContainerIDs(ExportJob job) throws IOException {
+    final byte[] buffer = new byte[job.getBatchSize() * 12];
+    int partNumber = 1;
+    long idsWritten = 0;
+    ContainerID cursor = job.getStartContainerId();
+    List<ContainerID> batch = Collections.emptyList();
+    int batchIndex = 0;
+
+    while (cursor != null || batchIndex < batch.size()) {
+      if (Thread.interrupted()) {
+        return null;
+      }
+      if (!isLeaderReady.getAsBoolean()) {
+        throw new IOException("SCM lost leadership during " + job);
+      }
+
+      if (batchIndex >= batch.size()) {
+        batch = fetchContainerIds(job, cursor);
+        batchIndex = 0;
+        if (batch.isEmpty()) {
+          break;
+        }
+        cursor = nextCursor(batch);
+      }
+
+      final ContainerID partStartContainerId = batch.get(batchIndex);
+      try (OutputStream out = fileManager.newPartOutputStream(job.getId(), 
job.partFileName(partNumber))) {
+        job.writeMetadataHeader(out, partNumber, partStartContainerId);
+        LOG.info("{} created part{}", job, partNumber);
+
+        long idsInCurrentPart = 0;
+        int offset = 0;
+        while (idsInCurrentPart < job.getPartSize()
+            && (batchIndex < batch.size() || cursor != null)) {
+          if (batchIndex >= batch.size()) {
+            batch = fetchContainerIds(job, cursor);
+            batchIndex = 0;
+            if (batch.isEmpty()) {
+              cursor = null;
+              break;
+            }
+            cursor = nextCursor(batch);
+          }
+
+          final ContainerID containerId = batch.get(batchIndex++);
+          final byte[] idBytes = (containerId.getProtobuf().getId() + 
"\n").getBytes(StandardCharsets.UTF_8);
+          if (offset + idBytes.length > buffer.length) {
+            out.write(buffer, 0, offset);
+            offset = 0;
+          }
+          System.arraycopy(idBytes, 0, buffer, offset, idBytes.length);
+          offset += idBytes.length;
+          idsWritten++;
+          idsInCurrentPart++;
+        }
+
+        if (offset > 0) {
+          out.write(buffer, 0, offset);
+        }
+      }
+      partNumber++;
+    }
+    return idsWritten;
+  }
+
+  private static ContainerID nextCursor(List<ContainerID> batch) {
+    return ContainerID.valueOf(batch.get(batch.size() - 
1).getProtobuf().getId() + 1);
+  }
+
+  private List<ContainerID> fetchContainerIds(ExportJob job, ContainerID 
cursor) throws IOException {
+    if (!isLeaderReady.getAsBoolean()) {
+      throw new IOException("SCM lost leadership during " + job);
+    }
+    return containerManager.getContainerIDs(
+        cursor, job.getBatchSize(), job.getLifeCycleState(), 
job.getHealthState());
+  }
+}
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
index 5ebbf012970..7dc37847cc8 100644
--- 
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
@@ -17,26 +17,28 @@
 
 package org.apache.hadoop.hdds.scm.container.export;
 
-import java.io.BufferedWriter;
 import java.io.File;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.io.RandomAccessFile;
 import java.nio.channels.FileLock;
 import java.nio.channels.OverlappingFileLockException;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.ArrayList;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.Comparator;
-import java.util.List;
 import java.util.Objects;
+import java.util.function.BooleanSupplier;
 import java.util.zip.GZIPOutputStream;
 import org.apache.commons.compress.archivers.ArchiveOutputStream;
 import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
 import org.apache.commons.io.FileUtils;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.server.ServerUtils;
 import org.apache.hadoop.hdds.utils.Archiver;
 import org.apache.hadoop.ozone.util.UUIDUtil;
 import org.apache.ratis.util.AtomicFileOutputStream;
@@ -67,22 +69,23 @@
  * </pre>
  *
  * <p><b>Incomplete work</b> ({@code export_{jobId}/} and {@code .tar.gz.tmp}) 
is removed by
- * {@link #cleanupFailedJob(ExportJob.Id, String)} on failure or cancel, and 
by {@link #start()} for every
+ * {@link #cleanupFailedJob(ExportJob)} on failure or cancel, and by {@link 
#start()} for every
  * leftover directory and temp file after SCM restart. Completed {@code 
.tar.gz} files are kept.
  *
  * <p><b>SCM restart:</b> {@link #start()} acquires the export directory lock 
and clears incomplete work.
- * {@link #listCompletedArchivePaths()} returns existing archive paths (oldest 
first).
  */
 final class ExportFileManager {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(ExportFileManager.class);
 
+  static final String EXPORT_SUBDIR = "exports";
   static final String EXPORT_JOB_DIR_PREFIX = "export_";
   static final String EXPORT_ARCHIVE_JOB_INFIX = "_job";
   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 static final int EXPORT_JOB_START_TIME_LENGTH = 19;
+  private static final DateTimeFormatter EXPORT_JOB_START_TIME_FORMAT =
+      
DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss").withZone(ZoneOffset.UTC);
 
   private final String exportDirectory;
   private FileLock exportDirectoryLock;
@@ -91,13 +94,22 @@ final class ExportFileManager {
     this.exportDirectory = Objects.requireNonNull(exportDirectory, 
"exportDirectory == null");
   }
 
+  static String resolveExportDirectory(OzoneConfiguration conf) {
+    File scmDbDir = ServerUtils.getScmDbDir(conf);
+    return new File(scmDbDir, EXPORT_SUBDIR).getAbsolutePath();
+  }
+
+  String getExportDirectory() {
+    return exportDirectory;
+  }
+
   void start() throws IOException {
     Files.createDirectories(Paths.get(exportDirectory));
     lock();
     removeIncompleteWorkOnStartup();
   }
 
-  private void lock() throws IOException {
+  private synchronized void lock() throws IOException {
     if (exportDirectoryLock != null) {
       return;
     }
@@ -117,96 +129,66 @@ private void lock() throws IOException {
     }
   }
 
-  File resolveArchiveFile(ExportScope scope, String jobStartTime, ExportJob.Id 
jobId) {
-    return new File(exportDirectory, String.format("container-ids_%s_%s%s%s%s",
-        scope.getValue(), jobStartTime, EXPORT_ARCHIVE_JOB_INFIX, 
jobId.getValue(), EXPORT_ARCHIVE_SUFFIX));
+  synchronized void unlock() throws IOException {
+    if (exportDirectoryLock == null) {
+      return;
+    }
+    exportDirectoryLock.release();
+    exportDirectoryLock.channel().close();
+    exportDirectoryLock = null;
   }
 
-  File resolveArchiveTempFile(ExportScope scope, String jobStartTime, 
ExportJob.Id jobId) {
-    return AtomicFileOutputStream.getTemporaryFile(resolveArchiveFile(scope, 
jobStartTime, jobId));
+  static String archiveFileName(ExportScope scope, String jobStartTime, 
ExportJob.Id jobId) {
+    return String.format("container-ids_%s_%s%s%s%s",
+        scope, jobStartTime, EXPORT_ARCHIVE_JOB_INFIX, jobId, 
EXPORT_ARCHIVE_SUFFIX);
   }
 
   void createJobDirectory(ExportJob.Id jobId) throws IOException {
     Files.createDirectories(jobDirectory(jobId));
   }
 
-  BufferedWriter newPartWriter(ExportJob.Id jobId, String partFileName) throws 
IOException {
-    return Files.newBufferedWriter(jobDirectory(jobId).resolve(partFileName), 
StandardCharsets.UTF_8);
+  OutputStream newPartOutputStream(ExportJob.Id jobId, String partFileName) 
throws IOException {
+    return Files.newOutputStream(jobDirectory(jobId).resolve(partFileName));
   }
 
-  void writeArchive(ExportJob.Id jobId, String archivePath) throws IOException 
{
-    Path jobDir = jobDirectory(jobId);
+  void writeArchive(ExportJob job, BooleanSupplier isLeaderReady) throws 
IOException {
+    Path jobDir = jobDirectory(job.getId());
     File[] parts = jobDir.toFile().listFiles((dir, name) -> 
name.endsWith(".txt"));
     if (parts == null || parts.length == 0) {
-      throw new IOException("No part files found for export job " + jobId);
+      throw new IOException("No part files found for " + job);
     }
     Arrays.sort(parts, Comparator.comparing(File::getName));
-    File archiveFile = new File(archivePath);
+    File archiveFile = new File(exportDirectory, job.getFileName());
     try (AtomicFileOutputStream atomicOut = new 
AtomicFileOutputStream(archiveFile);
          GZIPOutputStream gzipOut = new GZIPOutputStream(atomicOut);
          ArchiveOutputStream<TarArchiveEntry> tarOut = Archiver.tar(gzipOut)) {
       for (File part : parts) {
+        if (!isLeaderReady.getAsBoolean()) {
+          // close() would publish the partial archive; abort() discards the 
temp file instead.
+          atomicOut.abort();
+          throw new IOException("SCM lost leadership while archiving " + job);
+        }
         Archiver.includeFile(part, part.getName(), tarOut);
       }
     }
   }
 
-  void deleteJobDirectory(ExportJob.Id jobId) throws IOException {
+  void deleteJobDirectory(ExportJob.Id jobId) {
     Path jobDir = jobDirectory(jobId);
-    if (Files.exists(jobDir)) {
-      FileUtils.deleteDirectory(jobDir.toFile());
+    if (Files.exists(jobDir) && !FileUtils.deleteQuietly(jobDir.toFile())) {
+      LOG.warn("Failed to delete container export job directory {}; it will be 
removed on next SCM start", jobDir);
     }
   }
 
-  /**
-   * Returns completed archive paths, oldest first.
-   */
-  List<String> 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.comparing(
-        file -> jobStartTimeFromArchiveFileName(file.getName())));
-    List<String> archivePaths = new ArrayList<>(matches.length);
-    for (File archive : matches) {
-      archivePaths.add(archive.getAbsolutePath());
-    }
-    return archivePaths;
+  static String formatJobStartTime(Instant jobStartTime) {
+    return EXPORT_JOB_START_TIME_FORMAT.format(jobStartTime);
   }
 
-  static String jobStartTimeFromArchiveFileName(String fileName) {
-    int jobIndex = fileName.lastIndexOf(EXPORT_ARCHIVE_JOB_INFIX);
-    if (jobIndex < EXPORT_JOB_START_TIME_LENGTH + 1
-            || !fileName.endsWith(EXPORT_ARCHIVE_SUFFIX)
-            || fileName.endsWith(EXPORT_ARCHIVE_TMP_SUFFIX)) {
-      return null;
-    }
-    return fileName.substring(jobIndex - EXPORT_JOB_START_TIME_LENGTH, 
jobIndex);
-  }
-
-  static ExportJob.Id jobIdFromArchiveFileName(String fileName) {
-    if (!fileName.endsWith(EXPORT_ARCHIVE_SUFFIX)) {
-      return null;
-    }
-    String nameWithoutSuffix = fileName.substring(0, fileName.length() - 
EXPORT_ARCHIVE_SUFFIX.length());
-    int jobIndex = nameWithoutSuffix.lastIndexOf(EXPORT_ARCHIVE_JOB_INFIX);
-    if (jobIndex < 0) {
-      return null;
-    }
-    String jobId = nameWithoutSuffix.substring(jobIndex + 
EXPORT_ARCHIVE_JOB_INFIX.length());
-    return UUIDUtil.isValidUuidString(jobId) ? ExportJob.Id.of(jobId) : null;
-  }
-
-  void cleanupFailedJob(ExportJob.Id jobId, String archivePath) {
-    FileUtils.deleteQuietly(jobDirectory(jobId).toFile());
-    if (archivePath != null) {
-      File archive = new File(archivePath);
-      FileUtils.deleteQuietly(archive);
-      
FileUtils.deleteQuietly(AtomicFileOutputStream.getTemporaryFile(archive));
-    }
+  void cleanupFailedJob(ExportJob job) {
+    FileUtils.deleteQuietly(jobDirectory(job.getId()).toFile());
+    File archive = new File(exportDirectory, job.getFileName());
+    FileUtils.deleteQuietly(archive);
+    FileUtils.deleteQuietly(AtomicFileOutputStream.getTemporaryFile(archive));
   }
 
   private Path jobDirectory(ExportJob.Id jobId) {
@@ -235,7 +217,7 @@ private void removeIncompleteWorkOnStartup() {
   }
 
   static String exportJobDirName(ExportJob.Id jobId) {
-    return EXPORT_JOB_DIR_PREFIX + jobId.getValue();
+    return EXPORT_JOB_DIR_PREFIX + jobId;
   }
 
   private static ExportJob.Id jobIdFromExportDirName(String dirName) {
diff --git 
a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportJob.java
 
b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportJob.java
index ba66a4907f1..1d7a1e79353 100644
--- 
a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportJob.java
+++ 
b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportJob.java
@@ -19,17 +19,33 @@
 
 import java.io.BufferedWriter;
 import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.Objects;
 import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+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;
 
 /**
- * Metadata for a container ID export job.
+ * In-memory state for a container ID export job.
  */
 public final class ExportJob {
 
   private final Id id;
   private final ExportScope scope;
-  private final String jobStartTime;
+  private final String jobStartTime = 
ExportFileManager.formatJobStartTime(Instant.now());
+  private final ContainerID startContainerId;
+  private final int batchSize;
+  private final int partSize;
+  /** The .tar.gz output path, fixed at job creation; used by the worker to 
write the archive. */
+  private final String fileName;
+
+  private final CompletableFuture<Long> idsWrittenFuture = new 
CompletableFuture<>();
 
   /**
    * Unique job identifier.
@@ -49,10 +65,6 @@ public static Id of(String value) {
       return new Id(value);
     }
 
-    public String getValue() {
-      return value;
-    }
-
     @Override
     public String toString() {
       return value;
@@ -75,37 +87,122 @@ public int hashCode() {
     }
   }
 
-  ExportJob(Id id, ExportScope scope, String jobStartTime) {
-    this.id = id;
+  /**
+   * Immutable snapshot of export progress.
+   */
+  public final class Status {
+    private final Long idsWritten;
+    private final Throwable exception;
+
+    private Status() {
+      if (!idsWrittenFuture.isDone()) {
+        this.idsWritten = null;
+        this.exception = null;
+      } else {
+        long rows = -1;
+        Throwable cause = null;
+        try {
+          rows = idsWrittenFuture.join();
+        } catch (CompletionException e) {
+          cause = e.getCause();
+        }
+        this.idsWritten = rows;
+        this.exception = cause;
+      }
+    }
+
+    public ExportJob.Id getId() {
+      return id;
+    }
+
+    public boolean isDone() {
+      return idsWritten != null || exception != null;
+    }
+
+    public long getIdsWritten() {
+      if (!isDone()) {
+        throw new IllegalStateException("Export is not done");
+      }
+      if (exception != null) {
+        throw new IllegalStateException("Export failed", exception);
+      }
+      return idsWritten;
+    }
+
+    public Throwable getException() {
+      return exception;
+    }
+  }
+
+  ExportJob(ExportScope scope, ContainerID startContainerId, int batchSize, 
int partSize) {
+    this.id = Id.newId();
     this.scope = scope;
-    this.jobStartTime = jobStartTime;
+    this.startContainerId = startContainerId != null ? startContainerId : 
ContainerID.valueOf(0);
+    this.fileName = ExportFileManager.archiveFileName(scope, jobStartTime, id);
+    this.batchSize = batchSize;
+    this.partSize = partSize;
   }
 
-  String partFileName(int partIndex) {
-    return String.format("container-ids_%s_%s_part%03d.txt",
-        scope.getValue(), jobStartTime, partIndex);
+  Id getId() {
+    return id;
   }
 
-  void writeMetadataHeader(BufferedWriter writer, int partNumber, long 
partStartContainerId)
-      throws IOException {
-    writer.write("# jobId=" + id.getValue());
+  LifeCycleState getLifeCycleState() {
+    return scope.getLifeCycleState();
+  }
+
+  ContainerHealthState getHealthState() {
+    return scope.getHealthState();
+  }
+
+  String getFileName() {
+    return fileName;
+  }
+
+  ContainerID getStartContainerId() {
+    return startContainerId;
+  }
+
+  int getBatchSize() {
+    return batchSize;
+  }
+
+  int getPartSize() {
+    return partSize;
+  }
+
+  CompletableFuture<Long> getFuture() {
+    return idsWrittenFuture;
+  }
+
+  Status getStatus() {
+    return new Status();
+  }
+
+  String partFileName(int partNumber) {
+    return String.format("container-ids_%s_%s_part%03d.txt", scope, 
jobStartTime, partNumber);
+  }
+
+  void writeMetadataHeader(OutputStream out, int partNumber, ContainerID 
partStartContainerId) throws IOException {
+    final BufferedWriter writer = new BufferedWriter(new 
OutputStreamWriter(out, StandardCharsets.UTF_8));
+    writer.write("# jobId=" + id);
     writer.newLine();
     writer.write("# jobStartTime=" + jobStartTime);
     writer.newLine();
-    if (scope.getHealthState() != null) {
-      writer.write("# healthState=" + scope.getHealthState().name());
-      writer.newLine();
-    }
-    if (scope.getLifeCycleState() != null) {
-      writer.write("# lifecycleState=" + scope.getLifeCycleState().name());
-      writer.newLine();
-    }
-    writer.write("# startContainerId=" + partStartContainerId);
+    writer.write("# scope=" + scope);
+    writer.newLine();
+    writer.write("# partStartContainerId=" + partStartContainerId);
     writer.newLine();
     writer.write("# part=" + partNumber);
     writer.newLine();
     writer.write("# format=container-id-per-line");
     writer.newLine();
     writer.newLine();
+    writer.flush();
+  }
+  
+  @Override
+  public String toString() {
+    return "ExportJob-" + id;
   }
 }
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
index c6f174a3299..7d8a3c4b42f 100644
--- 
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
@@ -58,13 +58,6 @@ public ContainerHealthState getHealthState() {
     return healthState;
   }
 
-  /**
-   * Stable filter name segment used in export TAR and part 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/server/StorageContainerManager.java
 
b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java
index fa32b0b9705..8004619d772 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;
@@ -307,6 +308,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.
@@ -431,6 +433,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());
@@ -1570,6 +1575,7 @@ public void start() throws IOException {
 
     scmBlockManager.start();
     leaseManager.start();
+    containerExportManager.start();
 
     try {
       httpServer = new StorageContainerManagerHttpServer(configuration, this);
@@ -1647,6 +1653,7 @@ public void stop() {
     }
 
     stopReplicationManager();
+    stopContainerExportManager();
 
     try {
       LOG.info("Stopping the Datanode Admin Monitor.");
@@ -1780,6 +1787,15 @@ private void stopReplicationManager() {
     }
   }
 
+  private void stopContainerExportManager() {
+    try {
+      LOG.info("Shutting down Container Export Manager.");
+      containerExportManager.shutdown();
+    } catch (Exception ex) {
+      LOG.error("Container Export Manager shutdown failed.", ex);
+    }
+  }
+
   @Override
   public void shutDown(String message) {
     stop();
@@ -1917,6 +1933,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..9c60af26672
--- /dev/null
+++ 
b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestContainerExportManager.java
@@ -0,0 +1,525 @@
+/*
+ * 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.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.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BooleanSupplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+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.scm.exceptions.SCMException;
+import org.apache.hadoop.hdds.utils.Archiver;
+import org.apache.ozone.test.GenericTestUtils;
+import org.apache.ratis.util.AtomicFileOutputStream;
+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);
+    SCMException e = assertThrows(SCMException.class, () ->
+        exportManager.submitJob(ContainerID.valueOf(0), null, 
ContainerHealthState.MISSING));
+    assertEquals(SCMException.ResultCodes.SCM_NOT_LEADER, e.getResult());
+  }
+
+  @Test
+  public void testRejectSubmitAfterShutdown() {
+    exportManager.shutdown();
+    IOException e = assertThrows(IOException.class, () ->
+        exportManager.submitJob(ContainerID.valueOf(0), null, 
ContainerHealthState.MISSING));
+    assertTrue(e.getMessage().contains("shut down"));
+  }
+
+  @Test
+  public void testRejectNonPositiveSizes() {
+    assertThrows(IllegalArgumentException.class, () ->
+        new ContainerExportManager(TEST_SCM_ID, containerManager, () -> true, 
tempDir.getAbsolutePath(), 0, 1));
+    assertThrows(IllegalArgumentException.class, () ->
+        new ContainerExportManager(TEST_SCM_ID, containerManager, () -> true, 
tempDir.getAbsolutePath(), 1, 0));
+  }
+
+  @Test
+  public void testGetExportStatusUnknownJobReturnsNull() {
+    assertNull(exportManager.getExportStatus(ExportJob.Id.newId()));
+  }
+
+  @Test
+  public void testRejectConcurrentExport() throws Exception {
+    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 first = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.MISSING);
+    assertNotNull(first);
+    IOException e = assertThrows(IOException.class, () ->
+        exportManager.submitJob(ContainerID.valueOf(0), null, 
ContainerHealthState.EMPTY));
+    assertTrue(e.getMessage().contains("already running"));
+  }
+
+  @Test
+  public void testRejectSubmitWhileArchivingThenAcceptAfterDone() throws 
Exception {
+    final AtomicBoolean allIdsWritten = new AtomicBoolean();
+    final CountDownLatch archivingStarted = new CountDownLatch(1);
+    final CountDownLatch releaseArchiving = new CountDownLatch(1);
+    exportManager.shutdown();
+    exportManager = newExportManager(TEST_PART_SIZE, TEST_BATCH_SIZE, () -> {
+      // submitJob also calls this on the caller thread; only hold the worker.
+      if (allIdsWritten.get() && 
Thread.currentThread().getName().endsWith("-ContainerExportWorker")) {
+        archivingStarted.countDown();
+        try {
+          releaseArchiving.await();
+        } catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+        }
+      }
+      return true;
+    });
+
+    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 -> {
+          allIdsWritten.set(true);
+          return Collections.emptyList();
+        });
+
+    ExportJob first = exportManager.submitJob(ContainerID.valueOf(0), null, 
ContainerHealthState.MISSING);
+    assertTrue(archivingStarted.await(30, TimeUnit.SECONDS));
+    assertFalse(exportManager.getExportStatus(first.getId()).isDone());
+
+    IOException e = assertThrows(IOException.class, () ->
+        exportManager.submitJob(ContainerID.valueOf(0), null, 
ContainerHealthState.EMPTY));
+    assertTrue(e.getMessage().contains("already running"));
+
+    releaseArchiving.countDown();
+    ExportJob.Status status = waitForDone(first.getId());
+    assertNull(status.getException());
+    assertTrue(archiveFile(first).exists());
+    assertNotNull(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 job = exportManager.submitJob(null, null, 
ContainerHealthState.MISSING);
+    assertNotNull(job);
+    waitForDone(job.getId());
+
+    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 job = exportManager.submitJob(ContainerID.valueOf(5), null,
+        ContainerHealthState.MISSING);
+    assertNotNull(job);
+
+    ExportJob.Status status = waitForDone(job.getId());
+    assertNull(status.getException());
+    assertEquals(2, status.getIdsWritten());
+
+    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 job = exportManager.submitJob(ContainerID.valueOf(0), 
LifeCycleState.OPEN, null);
+    assertNotNull(job);
+    waitForDone(job.getId());
+
+    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 job = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.MISSING);
+    assertNotNull(job);
+
+    ExportJob.Status status = waitForDone(job.getId());
+    assertNull(status.getException());
+    assertEquals(0, status.getIdsWritten());
+    assertFalse(archiveFile(job).exists());
+  }
+
+  @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 job = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.MISSING);
+    assertNotNull(job);
+
+    ExportJob.Status status = waitForDone(job.getId());
+    assertNull(status.getException());
+    assertEquals(4, status.getIdsWritten());
+    
assertTrue(job.getFileName().endsWith(ExportFileManager.EXPORT_ARCHIVE_SUFFIX));
+    assertTrue(archiveFile(job).exists());
+  }
+
+  @Test
+  public void testMultiPartExportCreatesTar() throws Exception {
+    // partSize=3, batchSize=2: part 1 ends mid-batch (after 3), part 2 ends 
exactly where batch (5,6) runs out.
+    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(ids(5, 6));
+    when(containerManager.getContainerIDs(
+        eq(ContainerID.valueOf(7)), anyInt(), isNull(), 
eq(ContainerHealthState.MISSING)))
+        .thenReturn(ids(7));
+    when(containerManager.getContainerIDs(
+        eq(ContainerID.valueOf(8)), anyInt(), isNull(), 
eq(ContainerHealthState.MISSING)))
+        .thenReturn(Collections.emptyList());
+
+    ExportJob job = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.MISSING);
+    assertNotNull(job);
+
+    ExportJob.Status status = waitForDone(job.getId());
+    assertNull(status.getException());
+    assertEquals(7, status.getIdsWritten());
+    
assertTrue(job.getFileName().endsWith(ExportFileManager.EXPORT_ARCHIVE_SUFFIX));
+    File archive = archiveFile(job);
+    assertTrue(archive.exists());
+    
assertFalse(Files.exists(tempDir.toPath().resolve(ExportFileManager.exportJobDirName(job.getId()))));
+
+    Path extractDir = Files.createTempDirectory("export-archive");
+    try {
+      extractGzTar(archive, extractDir);
+      List<Path> parts;
+      try (Stream<Path> stream = Files.list(extractDir)) {
+        parts = stream.sorted().collect(Collectors.toList());
+      }
+      assertEquals(3, parts.size());
+      
assertTrue(parts.get(0).getFileName().toString().endsWith("part001.txt"));
+      
assertTrue(parts.get(2).getFileName().toString().endsWith("part003.txt"));
+      assertTrue(Files.readAllLines(parts.get(1)).contains("# 
partStartContainerId=#4"));
+      assertTrue(Files.readAllLines(parts.get(2)).contains("# 
partStartContainerId=#7"));
+
+      List<String> exportedIds = new ArrayList<>();
+      for (Path part : parts) {
+        for (String line : Files.readAllLines(part)) {
+          if (!line.isEmpty() && !line.startsWith("#")) {
+            exportedIds.add(line);
+          }
+        }
+      }
+      assertEquals(Arrays.asList("1", "2", "3", "4", "5", "6", "7"), 
exportedIds);
+    } finally {
+      FileUtils.deleteQuietly(extractDir.toFile());
+    }
+  }
+
+  @Test
+  public void testLeadershipLostWhileArchivingLeavesNoArchive() throws 
Exception {
+    ExportFileManager fileManager = new 
ExportFileManager(tempDir.getAbsolutePath());
+    ExportJob job = new ExportJob(ExportScope.of(null, 
ContainerHealthState.MISSING),
+        ContainerID.valueOf(0), TEST_BATCH_SIZE, TEST_PART_SIZE);
+    fileManager.createJobDirectory(job.getId());
+    try (OutputStream out = fileManager.newPartOutputStream(job.getId(), 
job.partFileName(1))) {
+      job.writeMetadataHeader(out, 1, ContainerID.valueOf(1));
+    }
+
+    // Lost before the first part is copied.
+    IOException e = assertThrows(IOException.class, () -> 
fileManager.writeArchive(job, () -> false));
+    assertTrue(e.getMessage().contains("lost leadership"));
+    assertFalse(archiveFile(job).exists());
+    
assertFalse(AtomicFileOutputStream.getTemporaryFile(archiveFile(job)).exists());
+
+    // Lost only after the last (here: the only) part is copied; the finished 
archive is kept.
+    final AtomicInteger checks = new AtomicInteger();
+    fileManager.writeArchive(job, () -> checks.incrementAndGet() <= 1);
+    assertEquals(1, checks.get());
+    assertTrue(archiveFile(job).exists());
+    
assertFalse(AtomicFileOutputStream.getTemporaryFile(archiveFile(job)).exists());
+  }
+
+  @Test
+  public void testLockReleasedOnlyAfterWorkerStops() throws Exception {
+    final CountDownLatch workerBusy = new CountDownLatch(1);
+    final CountDownLatch releaseWorker = new CountDownLatch(1);
+    when(containerManager.getContainerIDs(
+        eq(ContainerID.valueOf(0)), anyInt(), isNull(), 
eq(ContainerHealthState.MISSING)))
+        .thenAnswer(invocation -> {
+          workerBusy.countDown();
+          try {
+            releaseWorker.await();
+          } catch (InterruptedException ignored) {
+            // shutdownNow() interrupts; keep running to simulate a worker 
that is slow to stop.
+          }
+          releaseWorker.await();
+          return Collections.emptyList();
+        });
+
+    ExportJob job = exportManager.submitJob(ContainerID.valueOf(0), null, 
ContainerHealthState.MISSING);
+    assertTrue(workerBusy.await(30, TimeUnit.SECONDS));
+
+    // Returns after the 5s timeout while the worker is still running.
+    exportManager.shutdown();
+    ExportFileManager other = new ExportFileManager(tempDir.getAbsolutePath());
+    assertThrows(IOException.class, other::start);
+
+    releaseWorker.countDown();
+    GenericTestUtils.waitFor(() -> {
+      try {
+        other.start();
+        return true;
+      } catch (IOException ex) {
+        return false;
+      }
+    }, 100, 30_000);
+    other.unlock();
+    assertTrue(waitForDone(job.getId()).isDone());
+  }
+
+  @Test
+  public void testSubmitAfterPreviousJobCompletes() throws Exception {
+    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)))
+        .thenReturn(Collections.emptyList());
+    when(containerManager.getContainerIDs(
+        eq(ContainerID.valueOf(0)), anyInt(), isNull(), 
eq(ContainerHealthState.EMPTY)))
+        .thenReturn(ids(10));
+    when(containerManager.getContainerIDs(
+        eq(ContainerID.valueOf(11)), anyInt(), isNull(), 
eq(ContainerHealthState.EMPTY)))
+        .thenReturn(Collections.emptyList());
+
+    ExportJob first = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.MISSING);
+    waitForDone(first.getId());
+
+    ExportJob second = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.EMPTY);
+    assertNotNull(second);
+    ExportJob.Status status = waitForDone(second.getId());
+    assertNull(status.getException());
+    assertEquals(1, status.getIdsWritten());
+  }
+
+  @Test
+  public void testFailsWhenLeadershipLostDuringExport() throws Exception {
+    AtomicBoolean leader = new AtomicBoolean(true);
+    exportManager.shutdown();
+    exportManager = newExportManager(TEST_PART_SIZE, TEST_BATCH_SIZE, 
leader::get);
+
+    when(containerManager.getContainerIDs(
+        eq(ContainerID.valueOf(0)), anyInt(), isNull(), 
eq(ContainerHealthState.MISSING)))
+        .thenAnswer(invocation -> {
+          leader.set(false);
+          return ids(1);
+        });
+
+    ExportJob job = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.MISSING);
+    assertNotNull(job);
+
+    ExportJob.Status status = waitForDone(job.getId());
+    assertNotNull(status.getException());
+    assertTrue(status.getException().getMessage().contains("lost leadership"));
+  }
+
+  @Test
+  public void testFailsWhenContainerManagerThrows() throws Exception {
+    when(containerManager.getContainerIDs(
+        eq(ContainerID.valueOf(0)), anyInt(), isNull(), 
eq(ContainerHealthState.MISSING)))
+        .thenThrow(new RuntimeException("container listing failed"));
+
+    ExportJob job = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.MISSING);
+    assertNotNull(job);
+
+    ExportJob.Status status = waitForDone(job.getId());
+    assertNotNull(status.getException());
+    assertTrue(status.getException().getMessage().contains("container listing 
failed"));
+    
assertFalse(Files.exists(tempDir.toPath().resolve(ExportFileManager.exportJobDirName(job.getId()))));
+  }
+
+  @Test
+  public void testShutdownCancelsRunningExport() throws Exception {
+    when(containerManager.getContainerIDs(
+        eq(ContainerID.valueOf(0)), anyInt(), isNull(), 
eq(ContainerHealthState.MISSING)))
+        .thenAnswer(invocation -> {
+          Thread.sleep(60_000);
+          return Collections.emptyList();
+        });
+
+    ExportJob job = exportManager.submitJob(ContainerID.valueOf(0), null,
+        ContainerHealthState.MISSING);
+    assertNotNull(job);
+
+    exportManager.shutdown();
+    ExportJob.Status status = waitForDone(job.getId());
+    assertNotNull(status.getException());
+    exportManager = null;
+  }
+
+  private File archiveFile(ExportJob job) {
+    return new File(tempDir, job.getFileName());
+  }
+
+  private ContainerExportManager newExportManager(int partSize, int batchSize,
+      BooleanSupplier isLeaderReady) throws Exception {
+    ContainerExportManager manager = new ContainerExportManager(TEST_SCM_ID, 
containerManager, isLeaderReady,
+        tempDir.getAbsolutePath(), partSize, batchSize);
+    manager.start();
+    return manager;
+  }
+
+  private static List<ContainerID> ids(long... values) {
+    return Arrays.stream(values).mapToObj(ContainerID::valueOf)
+        .collect(Collectors.toList());
+  }
+
+  private ExportJob.Status waitForDone(ExportJob.Id jobId) throws Exception {
+    GenericTestUtils.waitFor(() -> {
+      ExportJob.Status status = exportManager.getExportStatus(jobId);
+      return status != null && status.isDone();
+    }, 100, 30_000);
+    return exportManager.getExportStatus(jobId);
+  }
+
+  private static void extractGzTar(File archive, Path extractDir) throws 
Exception {
+    Files.createDirectories(extractDir);
+    try (InputStream in = new 
GZIPInputStream(Files.newInputStream(archive.toPath()));
+         ArchiveInputStream<TarArchiveEntry> tarIn = Archiver.untar(in)) {
+      TarArchiveEntry entry;
+      while ((entry = tarIn.getNextEntry()) != null) {
+        Archiver.extractEntry(entry, tarIn, entry.getSize(), extractDir, 
extractDir.resolve(entry.getName()));
+      }
+    }
+  }
+}
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
deleted file mode 100644
index a97255ba198..00000000000
--- 
a/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestExportFileManager.java
+++ /dev/null
@@ -1,231 +0,0 @@
-/*
- * 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.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
-import java.util.UUID;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-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.utils.Archiver;
-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 {
-
-  private static final String TEST_JOB_START_TIME = "2026-01-01-12-00-00";
-
-  @TempDir
-  private File tempDir;
-
-  private ExportFileManager fileManager;
-
-  @BeforeEach
-  public void setup() throws Exception {
-    fileManager = new ExportFileManager(tempDir.getAbsolutePath());
-    fileManager.start();
-  }
-
-  @Test
-  public void testExportScopeUsesAnyForNullFilters() {
-    assertEquals("health-MISSING_lifecycle-ANY",
-        ExportScope.of(null, ContainerHealthState.MISSING).getValue());
-    assertEquals("health-ANY_lifecycle-OPEN",
-        ExportScope.of(LifeCycleState.OPEN, null).getValue());
-  }
-
-  @Test
-  public void testRejectMissingFilters() {
-    assertThrows(IllegalArgumentException.class, () -> ExportScope.of(null, 
null));
-  }
-
-  @Test
-  public void testResolveArchiveFile() {
-    ExportJob.Id jobId = ExportJob.Id.newId();
-    ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
-    File archive = fileManager.resolveArchiveFile(scope, TEST_JOB_START_TIME, 
jobId);
-    assertTrue(archive.getName().contains("health-MISSING_lifecycle-ANY_" + 
TEST_JOB_START_TIME));
-    
assertTrue(archive.getName().endsWith(ExportFileManager.EXPORT_ARCHIVE_JOB_INFIX
 + jobId.getValue()
-        + ExportFileManager.EXPORT_ARCHIVE_SUFFIX));
-  }
-
-  @Test
-  public void testResolveArchiveTempFile() {
-    ExportJob.Id jobId = ExportJob.Id.newId();
-    ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
-    File tempFile = fileManager.resolveArchiveTempFile(scope, 
TEST_JOB_START_TIME, jobId);
-    
assertTrue(tempFile.getName().endsWith(ExportFileManager.EXPORT_ARCHIVE_TMP_SUFFIX));
-  }
-
-  @Test
-  public void testJobIdFromArchiveFileName() {
-    String jobId = UUID.randomUUID().toString();
-    String fileName = "container-ids_health-MISSING_lifecycle-ANY_" + 
TEST_JOB_START_TIME
-        + ExportFileManager.EXPORT_ARCHIVE_JOB_INFIX + jobId + 
ExportFileManager.EXPORT_ARCHIVE_SUFFIX;
-    assertEquals(ExportJob.Id.of(jobId), 
ExportFileManager.jobIdFromArchiveFileName(fileName));
-    
assertNull(ExportFileManager.jobIdFromArchiveFileName("container-ids_health-MISSING_lifecycle-ANY_"
-        + TEST_JOB_START_TIME + ExportFileManager.EXPORT_ARCHIVE_SUFFIX));
-  }
-
-  @Test
-  public void testJobStartTimeFromArchiveFileName() {
-    String fileName = "container-ids_health-MISSING_lifecycle-ANY_" + 
TEST_JOB_START_TIME
-        + ExportFileManager.EXPORT_ARCHIVE_JOB_INFIX + UUID.randomUUID() + 
ExportFileManager.EXPORT_ARCHIVE_SUFFIX;
-    assertEquals(TEST_JOB_START_TIME, 
ExportFileManager.jobStartTimeFromArchiveFileName(fileName));
-  }
-
-  @Test
-  public void testListCompletedArchivePaths() throws Exception {
-    ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
-    ExportJob.Id olderJobId = ExportJob.Id.newId();
-    File olderArchive = fileManager.resolveArchiveFile(scope, 
"2026-01-01-12-00-00", olderJobId);
-    assertTrue(olderArchive.createNewFile());
-    assertTrue(olderArchive.setLastModified(2_000L));
-    ExportJob.Id newerJobId = ExportJob.Id.newId();
-    File newerArchive = fileManager.resolveArchiveFile(scope, 
"2026-01-01-12-00-01", newerJobId);
-    assertTrue(newerArchive.createNewFile());
-    assertTrue(newerArchive.setLastModified(1_000L));
-    ExportJob.Id tempJobId = ExportJob.Id.newId();
-    File tempArchive = fileManager.resolveArchiveTempFile(scope, 
"2026-01-01-12-00-02", tempJobId);
-    assertTrue(tempArchive.createNewFile());
-
-    List<String> completedPaths = fileManager.listCompletedArchivePaths();
-    assertEquals(2, completedPaths.size());
-    assertEquals(olderArchive.getAbsolutePath(), completedPaths.get(0));
-    assertEquals(newerArchive.getAbsolutePath(), completedPaths.get(1));
-  }
-
-  @Test
-  public void testWriteArchiveFromPartFiles() throws Exception {
-    ExportJob.Id jobId = ExportJob.Id.newId();
-    ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
-    ExportJob job = new ExportJob(jobId, scope, TEST_JOB_START_TIME);
-    File archive = fileManager.resolveArchiveFile(scope, TEST_JOB_START_TIME, 
jobId);
-
-    fileManager.createJobDirectory(jobId);
-    try (BufferedWriter writer = fileManager.newPartWriter(jobId, 
job.partFileName(1))) {
-      job.writeMetadataHeader(writer, 1, 1L);
-      writer.write("1\n2\n");
-    }
-
-    fileManager.writeArchive(jobId, archive.getAbsolutePath());
-    fileManager.deleteJobDirectory(jobId);
-
-    assertTrue(archive.exists());
-    Path extractDir = Files.createTempDirectory("export-archive");
-    try {
-      extractGzTar(archive, extractDir);
-      List<String> partNames;
-      try (Stream<Path> stream = Files.list(extractDir)) {
-        partNames = stream.map(path -> 
path.getFileName().toString()).collect(Collectors.toList());
-      }
-      assertEquals(1, partNames.size());
-      assertTrue(partNames.get(0).endsWith("part001.txt"));
-    } finally {
-      FileUtils.deleteQuietly(extractDir.toFile());
-    }
-  }
-
-  @Test
-  public void testCleanupFailedJobRemovesJobDirAndTempArchive() throws 
Exception {
-    ExportJob.Id jobId = ExportJob.Id.newId();
-    ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
-    File archive = fileManager.resolveArchiveFile(scope, TEST_JOB_START_TIME, 
jobId);
-    File tempArchive = fileManager.resolveArchiveTempFile(scope, 
TEST_JOB_START_TIME, jobId);
-    assertTrue(tempArchive.createNewFile());
-
-    fileManager.createJobDirectory(jobId);
-    fileManager.cleanupFailedJob(jobId, archive.getAbsolutePath());
-
-    
assertFalse(Files.exists(tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId))));
-    assertFalse(tempArchive.exists());
-    assertFalse(archive.exists());
-  }
-
-  @Test
-  public void testOrphanJobDirRemovedOnStartup() throws Exception {
-    ExportJob.Id jobId = ExportJob.Id.newId();
-    Path orphanJobDir = 
tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId));
-    Files.createDirectories(orphanJobDir);
-
-    fileManager.start();
-
-    assertFalse(Files.exists(orphanJobDir));
-  }
-
-  @Test
-  public void testIncompleteExportArtifactsRemovedOnStartup() throws Exception 
{
-    ExportJob.Id jobId = ExportJob.Id.newId();
-    Path jobDir = 
tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId));
-    Files.createDirectories(jobDir);
-    ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
-    File partialArchiveTemp = fileManager.resolveArchiveTempFile(scope, 
"2026-01-01-00-00-00", jobId);
-    assertTrue(partialArchiveTemp.createNewFile());
-
-    fileManager.start();
-
-    assertFalse(Files.exists(jobDir));
-    assertFalse(partialArchiveTemp.exists());
-  }
-
-  @Test
-  public void testOrphanJobDirDoesNotDeleteCompletedTar() throws Exception {
-    ExportJob.Id jobId = ExportJob.Id.newId();
-    ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING);
-    File completedArchive = fileManager.resolveArchiveFile(scope, 
"2026-01-01-00-00-00", jobId);
-    assertTrue(completedArchive.createNewFile());
-    Path orphanJobDir = 
tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId));
-    Files.createDirectories(orphanJobDir);
-
-    fileManager.start();
-
-    assertTrue(completedArchive.exists());
-    assertFalse(Files.exists(orphanJobDir));
-  }
-
-  private static void extractGzTar(File archive, Path extractDir) throws 
Exception {
-    Files.createDirectories(extractDir);
-    try (InputStream in = new 
GZIPInputStream(Files.newInputStream(archive.toPath()));
-         ArchiveInputStream<TarArchiveEntry> tarIn = Archiver.untar(in)) {
-      TarArchiveEntry entry;
-      while ((entry = tarIn.getNextEntry()) != null) {
-        Archiver.extractEntry(entry, tarIn, entry.getSize(), extractDir, 
extractDir.resolve(entry.getName()));
-      }
-    }
-  }
-}
diff --git 
a/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestExportJob.java
 
b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestExportJob.java
deleted file mode 100644
index fdad57b4dc3..00000000000
--- 
a/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestExportJob.java
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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.assertTrue;
-
-import java.io.BufferedWriter;
-import java.io.File;
-import java.nio.file.Files;
-import java.util.List;
-import org.apache.hadoop.hdds.protocol.proto.HddsProtos.LifeCycleState;
-import org.apache.hadoop.hdds.scm.container.ContainerHealthState;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
-/**
- * Tests for {@link ExportJob}.
- */
-public class TestExportJob {
-
-  private static final String TEST_JOB_START_TIME = "2026-01-01-12-00-00";
-
-  @TempDir
-  private File tempDir;
-
-  @Test
-  public void testPartFileName() {
-    ExportJob job = newJob(ContainerHealthState.MISSING, null);
-    
assertEquals("container-ids_health-MISSING_lifecycle-ANY_2026-01-01-12-00-00_part001.txt",
 job.partFileName(1));
-  }
-
-  @Test
-  public void testWriteMetadataHeader() throws Exception {
-    ExportJob job = newJob(ContainerHealthState.MISSING, LifeCycleState.OPEN);
-    File headerFile = new File(tempDir, "part-header.txt");
-    try (BufferedWriter writer = Files.newBufferedWriter(headerFile.toPath())) 
{
-      job.writeMetadataHeader(writer, 2, 42L);
-    }
-    List<String> lines = Files.readAllLines(headerFile.toPath());
-    assertTrue(lines.contains("# jobStartTime=" + TEST_JOB_START_TIME));
-    assertTrue(lines.contains("# healthState=MISSING"));
-    assertTrue(lines.contains("# lifecycleState=OPEN"));
-    assertTrue(lines.contains("# startContainerId=42"));
-    assertTrue(lines.contains("# part=2"));
-  }
-
-  private static ExportJob newJob(ContainerHealthState healthState, 
LifeCycleState lifeCycleState) {
-    ExportScope scope = ExportScope.of(lifeCycleState, healthState);
-    return new ExportJob(ExportJob.Id.newId(), scope, TEST_JOB_START_TIME);
-  }
-}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to