diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/util/UUIDUtil.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/util/UUIDUtil.java index 8f4da0cfc46a..ef1de0d6aa9d 100644 --- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/util/UUIDUtil.java +++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/util/UUIDUtil.java @@ -19,6 +19,7 @@ import java.security.SecureRandom; import java.util.Random; +import java.util.UUID; import java.util.function.Consumer; /** @@ -47,6 +48,14 @@ private static byte[] getUUIDBytes(Consumer generator) { return bytes; } + public static boolean isValidUuidString(String value) { + try { + return value.equals(UUID.fromString(value).toString()); + } catch (IllegalArgumentException e) { + return false; + } + } + private UUIDUtil() { } } 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..e0bca340a241 --- /dev/null +++ b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportFileManager.java @@ -0,0 +1,234 @@ +/* + * 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 org.apache.commons.io.FileUtils; +import org.apache.hadoop.ozone.util.UUIDUtil; +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}_job{jobId}.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()} uses {@code in_use.lock} to exclude concurrent writers. + * + *

+ * {exportDirectory}/
+ * ├── in_use.lock
+ * ├── container-ids_{scope}_{timestamp}_job{jobId}.tar.gz
+ * ├── container-ids_{scope}_{timestamp}_job{jobId}.tar.gz.tmp
+ * └── export_{jobId}/
+ *     ├── container-ids_{scope}_{metadataTimestamp}_part001.txt
+ *     └── ...
+ * 
+ * + *

Incomplete work ({@code export_{jobId}/} and {@code .tar.gz.tmp}) is removed by + * {@link #cleanupFailedJob(Path, File)} 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. + * + *

Completed {@code .tar.gz} remains on disk until the export manager evicts it + * ({@code maxTerminalJobs} in {@code ContainerExportManager}) via {@link #deleteExportTar(String)}. + * + *

SCM restart: in-memory job status is lost. {@link #start()} clears incomplete work; + * {@link #listCompletedArchivePaths()} returns existing {@code tarPath} values (oldest first); + * {@link #jobIdFromArchiveFileName(String)} parses {@code jobId} for terminal-job rebuild in + * {@code ContainerExportManager}. + */ +final class ExportFileManager { + + private static final Logger LOG = LoggerFactory.getLogger(ExportFileManager.class); + + 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 ARCHIVE_TIMESTAMP_LENGTH = 16; + + 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)); + removeIncompleteWorkOnStartup(); + } + + 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 archiveTimestamp, ExportJob.Id jobId) { + return new File(exportDirectory, String.format("container-ids_%s_%s%s%s%s", + scope.getValue(), archiveTimestamp, EXPORT_ARCHIVE_JOB_INFIX, jobId.getValue(), EXPORT_ARCHIVE_SUFFIX)); + } + + File resolveArchiveTempFile(ExportScope scope, String archiveTimestamp, ExportJob.Id jobId) { + return AtomicFileOutputStream.getTemporaryFile(resolveArchiveFile(scope, archiveTimestamp, jobId)); + } + + /** + * 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.comparing( + file -> archiveTimestampFromArchiveFileName(file.getName()))); + List archivePaths = new ArrayList<>(matches.length); + for (File archive : matches) { + archivePaths.add(archive.getAbsolutePath()); + } + return archivePaths; + } + + static String archiveTimestampFromArchiveFileName(String fileName) { + int jobIndex = fileName.lastIndexOf(EXPORT_ARCHIVE_JOB_INFIX); + if (jobIndex < ARCHIVE_TIMESTAMP_LENGTH + 1 + || !fileName.endsWith(EXPORT_ARCHIVE_SUFFIX) + || fileName.endsWith(EXPORT_ARCHIVE_TMP_SUFFIX)) { + return null; + } + return fileName.substring(jobIndex - ARCHIVE_TIMESTAMP_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 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 cleanupFailedJob(Path jobDir, File archiveFile) { + if (jobDir != null) { + FileUtils.deleteQuietly(jobDir.toFile()); + } + if (archiveFile != null) { + FileUtils.deleteQuietly(AtomicFileOutputStream.getTemporaryFile(archiveFile)); + } + } + + private void removeIncompleteWorkOnStartup() { + File exportDir = new File(exportDirectory); + File[] children = exportDir.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isDirectory() && jobIdFromExportDirName(child.getName()) != null) { + FileUtils.deleteQuietly(child); + LOG.debug("Removed incomplete container export job directory: {}", child.getAbsolutePath()); + } + } + } + File[] tempFiles = exportDir.listFiles((dir, fileName) -> fileName.endsWith(EXPORT_ARCHIVE_TMP_SUFFIX)); + if (tempFiles != null) { + for (File tempFile : tempFiles) { + if (FileUtils.deleteQuietly(tempFile)) { + LOG.debug("Removed incomplete container export archive temp file: {}", tempFile.getName()); + } + } + } + } + + static String exportJobDirName(ExportJob.Id jobId) { + return EXPORT_JOB_DIR_PREFIX + jobId.getValue(); + } + + private static ExportJob.Id jobIdFromExportDirName(String dirName) { + if (!dirName.startsWith(EXPORT_JOB_DIR_PREFIX)) { + return null; + } + String jobId = dirName.substring(EXPORT_JOB_DIR_PREFIX.length()); + return UUIDUtil.isValidUuidString(jobId) ? ExportJob.Id.of(jobId) : null; + } +} 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 new file mode 100644 index 000000000000..f9dee07eaea1 --- /dev/null +++ b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportJob.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 java.util.Objects; +import java.util.UUID; + +/** + * Container ID export job identifier. + */ +public final class ExportJob { + + /** + * Unique job identifier. + */ + public static final class Id { + private final String value; + + private Id(String value) { + this.value = Objects.requireNonNull(value, "value == null"); + } + + public static Id newId() { + return new Id(UUID.randomUUID().toString()); + } + + public static Id of(String value) { + return new Id(value); + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Id)) { + return false; + } + return value.equals(((Id) obj).value); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + } + + private ExportJob() { + } +} 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..921fdf7f588f --- /dev/null +++ b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/container/export/ExportScope.java @@ -0,0 +1,68 @@ +/* + * 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_job{jobId}.tar.gz} + */ +public final class ExportScope { + + private static final String ANY = "ANY"; + 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) { + String health = healthState != null ? healthState.name() : ANY; + String lifecycle = lifeCycleState != null ? lifeCycleState.name() : ANY; + String value = "health-" + health + "_lifecycle-" + lifecycle; + return new ExportScope(lifeCycleState, healthState, value); + } + + 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..51bb88a56f50 --- /dev/null +++ b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/container/export/TestExportFileManager.java @@ -0,0 +1,156 @@ +/* + * 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.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.protocol.proto.HddsProtos.LifeCycleState; +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 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 testResolveArchiveFile() { + ExportJob.Id jobId = ExportJob.Id.newId(); + ExportScope scope = ExportScope.of(null, ContainerHealthState.MISSING); + File archive = fileManager.resolveArchiveFile(scope, "20260101T120000Z", jobId); + assertTrue(archive.getName().contains("health-MISSING_lifecycle-ANY_20260101T120000Z")); + 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, "20260101T120000Z", 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_20260101T120000Z" + + 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_20260101T120000Z" + + ExportFileManager.EXPORT_ARCHIVE_SUFFIX)); + } + + @Test + public void testArchiveTimestampFromArchiveFileName() { + String fileName = "container-ids_health-MISSING_lifecycle-ANY_20260101T120000Z" + + ExportFileManager.EXPORT_ARCHIVE_JOB_INFIX + UUID.randomUUID() + ExportFileManager.EXPORT_ARCHIVE_SUFFIX; + assertEquals("20260101T120000Z", ExportFileManager.archiveTimestampFromArchiveFileName(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, "20260101T120000Z", olderJobId); + assertTrue(olderArchive.createNewFile()); + assertTrue(olderArchive.setLastModified(2_000L)); + ExportJob.Id newerJobId = ExportJob.Id.newId(); + File newerArchive = fileManager.resolveArchiveFile(scope, "20260101T120001Z", newerJobId); + assertTrue(newerArchive.createNewFile()); + assertTrue(newerArchive.setLastModified(1_000L)); + ExportJob.Id tempJobId = ExportJob.Id.newId(); + File tempArchive = fileManager.resolveArchiveTempFile(scope, "20260101T120002Z", tempJobId); + 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 { + 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, "20260101T000000Z", 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, "20260101T000000Z", jobId); + assertTrue(completedArchive.createNewFile()); + Path orphanJobDir = tempDir.toPath().resolve(ExportFileManager.exportJobDirName(jobId)); + Files.createDirectories(orphanJobDir); + + fileManager.start(); + + assertTrue(completedArchive.exists()); + assertFalse(Files.exists(orphanJobDir)); + } +}