diff --git a/changelog/unreleased/fix-s3-restore-missing-directory-marker.yml b/changelog/unreleased/fix-s3-restore-missing-directory-marker.yml new file mode 100644 index 00000000000..3c1274dfc20 --- /dev/null +++ b/changelog/unreleased/fix-s3-restore-missing-directory-marker.yml @@ -0,0 +1,9 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: The S3 backup repository no longer writes or depends on zero-byte directory marker objects, so S3 backups that were copied with tools that drop those markers (such as `aws s3 sync`) can be restored again. A backup can also be written to an S3 location that does not exist yet. +type: fixed +authors: + - name: Samuel Verstraete + nick: elangelo +links: + - name: PR#5128 + url: https://github.com/apache/solr/pull/5128 diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java index b8f86af5e3e..3d13ea6ac53 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java @@ -166,6 +166,20 @@ default URI resolveDirectory(URI baseUri, String... pathComponents) { */ void deleteDirectory(URI path) throws IOException; + /** + * Whether this repository can hold an empty directory. + * + *

Object stores such as S3 have no real directories: a directory only exists while at least + * one file is stored under its path, and {@link #createDirectory(URI)} persists nothing. Such + * repositories return false, which lets a backup be written to a location that does not exist + * yet. + * + * @return true if a directory created by {@link #createDirectory(URI)} exists while it is empty + */ + default boolean supportsEmptyDirectories() { + return true; + } + /** * Copy a file from specified sourceDir to the destination repository (i.e. backup). * diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java index e3b27cb073c..54cd5845842 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java @@ -76,6 +76,11 @@ public boolean exists(URI path) throws IOException { return delegate.exists(path); } + @Override + public boolean supportsEmptyDirectories() { + return delegate.supportsEmptyDirectories(); + } + @Override public PathType getPathType(URI path) throws IOException { return delegate.getPathType(path); diff --git a/solr/core/src/java/org/apache/solr/handler/SnapShooter.java b/solr/core/src/java/org/apache/solr/handler/SnapShooter.java index 81b2e9bc397..9de40d225f1 100644 --- a/solr/core/src/java/org/apache/solr/handler/SnapShooter.java +++ b/solr/core/src/java/org/apache/solr/handler/SnapShooter.java @@ -151,7 +151,8 @@ protected void deleteSnapAsync(final ReplicationHandler replicationHandler) { public void validateCreateSnapshot() throws IOException { // Note - Removed the current behavior of creating the directory hierarchy. // Do we really need to provide this support? - if (!backupRepo.exists(baseSnapDirPath)) { + // Checked even when a missing location is allowed, to fail early on an unreachable repository + if (!backupRepo.exists(baseSnapDirPath) && backupRepo.supportsEmptyDirectories()) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, " Directory does not exist: " + snapshotDirPath); } diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/BackupAPIBase.java b/solr/core/src/java/org/apache/solr/handler/admin/api/BackupAPIBase.java index b7028ff0405..14656547a5f 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/BackupAPIBase.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/BackupAPIBase.java @@ -46,6 +46,20 @@ protected String getAndValidateBackupLocation(String repositoryName, String loca } } + /** + * Like {@link #getAndValidateBackupLocation(String, String)}, for an API that writes a new backup + * to the location. On a repository that can't hold empty directories, a location that does not + * exist yet is accepted: it comes into existence with the first file of the backup. + */ + protected String getAndValidateNewBackupLocation(String repositoryName, String location) + throws IOException { + try (final var repository = createBackupRepository(repositoryName)) { + location = getLocation(repository, location); + ensureBackupLocationExists(repository, location, !repository.supportsEmptyDirectories()); + return location; + } + } + protected String getAndValidateIncrementalBackupLocation( String repositoryName, String location, String backupName) throws IOException { try (final var repository = createBackupRepository(repositoryName)) { @@ -80,10 +94,12 @@ private String getLocation(BackupRepository repository, String location) throws + " parameter or as a default repository property or as a cluster property."); } - private void ensureBackupLocationExists(BackupRepository repository, String location) { + private void ensureBackupLocationExists( + BackupRepository repository, String location, boolean allowMissing) { final URI uri = repository.createDirectoryURI(location); try { - if (!repository.exists(uri)) { + // Checked even when a missing location is allowed, to fail early on an unreachable repository + if (!repository.exists(uri) && !allowMissing) { throw new SolrException( SolrException.ErrorCode.SERVER_ERROR, "specified location " + uri + " does not exist."); } @@ -98,7 +114,7 @@ private void ensureBackupLocationExists(BackupRepository repository, String loca private String getAndValidateBackupLocation(BackupRepository backupRepository, String location) throws IOException { location = getLocation(backupRepository, location); - ensureBackupLocationExists(backupRepository, location); + ensureBackupLocationExists(backupRepository, location, false); return location; } diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CreateCollectionBackup.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CreateCollectionBackup.java index 5e5661378b3..0b8d0a67078 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/CreateCollectionBackup.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CreateCollectionBackup.java @@ -90,7 +90,7 @@ public SolrJerseyResponse createCollectionBackup( collectionName, Boolean.TRUE.equals(requestBody.followAliases)); requestBody.location = - getAndValidateBackupLocation(requestBody.repository, requestBody.location); + getAndValidateNewBackupLocation(requestBody.repository, requestBody.location); if (requestBody.backupStrategy == null) { requestBody.backupStrategy = CollectionAdminParams.COPY_FILES_STRATEGY; diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CreateCoreBackup.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CreateCoreBackup.java index 9299657daf8..d47260d9279 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/CreateCoreBackup.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CreateCoreBackup.java @@ -111,7 +111,10 @@ public SolrJerseyResponse createBackup( // already // exist here if triggered via a collection backup on a shared file system. Otherwise, // perhaps the FS location isn't shared -- we want an error. - if (!snapShooter.getBackupRepository().exists(snapShooter.getLocation())) { + // A repository that can't hold empty directories, such as S3, has no location until + // the first file is written to it, so a missing one is accepted there. + if (!snapShooter.getBackupRepository().exists(snapShooter.getLocation()) + && snapShooter.getBackupRepository().supportsEmptyDirectories()) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "Directory to contain snapshots doesn't exist: " diff --git a/solr/modules/s3-repository/src/java/org/apache/solr/s3/S3BackupRepository.java b/solr/modules/s3-repository/src/java/org/apache/solr/s3/S3BackupRepository.java index 23b1ed4c509..a4f8f14211e 100644 --- a/solr/modules/s3-repository/src/java/org/apache/solr/s3/S3BackupRepository.java +++ b/solr/modules/s3-repository/src/java/org/apache/solr/s3/S3BackupRepository.java @@ -146,6 +146,11 @@ public void createDirectory(URI path) throws IOException { client.createDirectory(s3Path); } + @Override + public boolean supportsEmptyDirectories() { + return false; + } + @Override public void deleteDirectory(URI path) throws IOException { Objects.requireNonNull(path, "cannot delete directory with a null URI"); @@ -285,7 +290,6 @@ public void copyIndexFileFrom( throw new CorruptIndexException("file is too small:" + indexInput.length(), indexInput); } - client.createDirectory(getS3Path(dest)); try (OutputStream outputStream = client.pushStream(s3Path)) { byte[] buffer = new byte[CHUNK_SIZE]; diff --git a/solr/modules/s3-repository/src/java/org/apache/solr/s3/S3StorageClient.java b/solr/modules/s3-repository/src/java/org/apache/solr/s3/S3StorageClient.java index bf076226685..527fb03b63b 100644 --- a/solr/modules/s3-repository/src/java/org/apache/solr/s3/S3StorageClient.java +++ b/solr/modules/s3-repository/src/java/org/apache/solr/s3/S3StorageClient.java @@ -41,10 +41,8 @@ import software.amazon.awssdk.awscore.exception.AwsServiceException; import software.amazon.awssdk.awscore.retry.AwsRetryStrategy; import software.amazon.awssdk.core.ResponseInputStream; -import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.core.retry.RetryMode; -import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.ResponseTransformer; import software.amazon.awssdk.http.apache.ApacheHttpClient; import software.amazon.awssdk.http.apache.ProxyConfiguration; @@ -64,7 +62,6 @@ import software.amazon.awssdk.services.s3.model.NoSuchBucketException; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; import software.amazon.awssdk.services.s3.model.ObjectIdentifier; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.S3Object; import software.amazon.awssdk.services.s3.paginators.ListObjectsV2Iterable; @@ -84,9 +81,6 @@ public class S3StorageClient { // S3 has a hard limit of 1000 keys per batch delete request private static final int MAX_KEYS_PER_BATCH_DELETE = 1000; - // Metadata name used to identify flag directory entries in S3 - private static final String S3_DIR_CONTENT_TYPE = "application/x-directory"; - // Error messages returned by S3 for a key not found. private static final Set NOT_FOUND_CODES = Set.of("NoSuchKey", "404 Not Found"); @@ -182,30 +176,13 @@ private static S3Client createInternalClient( return clientBuilder.build(); } - /** Create a directory in S3, if it does not already exist. */ - void createDirectory(String path) throws S3Exception { - String sanitizedDirPath = sanitizedDirPath(path); - - // Only create the directory if it does not already exist - if (!pathExists(sanitizedDirPath)) { - createDirectory(getParentDirectory(sanitizedDirPath)); - // TODO see https://issues.apache.org/jira/browse/SOLR-15359 - // throw new S3Exception("Parent directory doesn't exist, path=" + path); - - try { - // Create empty object with content type header - PutObjectRequest putRequest = - PutObjectRequest.builder() - .bucket(bucketName) - .contentType(S3_DIR_CONTENT_TYPE) - .key(sanitizedDirPath) - .build(); - s3Client.putObject(putRequest, RequestBody.empty()); - } catch (SdkClientException ase) { - throw handleAmazonException(ase); - } - } - } + /** + * No-op: S3 has no directories, a "directory" is just a key prefix that exists as soon as an + * object is written under it. Older Solr versions wrote zero-byte marker objects here, but those + * get lost when a bucket is copied with tools such as {@code aws s3 sync}, so nothing may depend + * on them. + */ + void createDirectory(String path) {} /** * Delete files from S3. Deletion order is not guaranteed. @@ -239,9 +216,6 @@ void deleteDirectory(String path) throws S3Exception { // Get all the files and subdirectories Set entries = listAll(path); - if (pathExists(path)) { - entries.add(path); - } deleteObjects(entries); } @@ -292,10 +266,11 @@ String[] listDir(String path) throws S3Exception { } /** - * Check if path exists. + * Check if path exists. A directory path (ending with {@code /}) exists if any object is stored + * under that prefix, including a directory marker object written by older Solr versions. * * @param path to File/Directory in S3. - * @return true if path exists, otherwise false? + * @return true if path exists, otherwise false */ boolean pathExists(String path) throws S3Exception { final String s3Path = sanitizedPath(path); @@ -305,6 +280,10 @@ boolean pathExists(String path) throws S3Exception { return true; } + if (s3Path.endsWith(S3_FILE_PATH_DELIMITER)) { + return hasObjectsUnderPrefix(s3Path); + } + try { s3Client.headObject(builder -> builder.bucket(bucketName).key(s3Path)); return true; @@ -316,23 +295,22 @@ boolean pathExists(String path) throws S3Exception { } /** - * Check if path is directory. + * Check if path is directory, i.e. if any object is stored under the {@code path + "/"} prefix. * * @param path to File/Directory in S3. * @return true if path is directory, otherwise false. */ boolean isDirectory(String path) throws S3Exception { - final String s3Path = sanitizedDirPath(path); + return hasObjectsUnderPrefix(sanitizedDirPath(path)); + } + private boolean hasObjectsUnderPrefix(String prefix) throws S3Exception { try { - HeadObjectResponse objectMetadata = - s3Client.headObject(builder -> builder.bucket(bucketName).key(s3Path)); - String contentType = objectMetadata.contentType(); - - return StrUtils.isNotNullOrEmpty(contentType) - && contentType.equalsIgnoreCase(S3_DIR_CONTENT_TYPE); - } catch (NoSuchKeyException nske) { - return false; + // listObjectsV2's prefix already guarantees every returned key starts with it. + return !s3Client + .listObjectsV2(b -> b.bucket(bucketName).prefix(prefix).maxKeys(1)) + .contents() + .isEmpty(); } catch (SdkException sdke) { throw handleAmazonException(sdke); } @@ -346,19 +324,18 @@ boolean isDirectory(String path) throws S3Exception { */ long length(String path) throws S3Exception { String s3Path = sanitizedFilePath(path); - if (isDirectory(s3Path)) { - throw new S3Exception("Path is Directory"); - } try { + // sanitizedFilePath rejects a trailing '/', so a successful HEAD here can never land on a + // directory marker object (whose key always ends in '/'); it's always a real file. HeadObjectResponse objectMetadata = s3Client.headObject(b -> b.bucket(bucketName).key(s3Path)); - String contentType = objectMetadata.contentType(); - - if (StrUtils.isNullOrEmpty(contentType) - || !contentType.equalsIgnoreCase(S3_DIR_CONTENT_TYPE)) { - return objectMetadata.contentLength(); + return objectMetadata.contentLength(); + } catch (NoSuchKeyException nske) { + // Only pay for the extra listing when the file is missing + if (isDirectory(s3Path)) { + throw new S3Exception("Path is Directory"); } - throw new S3Exception("Path is Directory"); + throw handleAmazonException(nske); } catch (SdkException sdke) { throw handleAmazonException(sdke); } @@ -409,10 +386,6 @@ InputStream pullStream(String path) throws S3Exception { OutputStream pushStream(String path) throws S3Exception { path = sanitizedFilePath(path); - if (!parentDirectoryExist(path)) { - throw new S3Exception("Parent directory doesn't exist of path: " + path); - } - try { return new S3OutputStream(s3Client, path, bucketName); } catch (SdkException sdke) { @@ -519,34 +492,6 @@ private Set listAll(String path) throws S3Exception { } } - private boolean parentDirectoryExist(String path) throws S3Exception { - // Get the last non-slash character of the string, to find the parent directory - String parentDirectory = getParentDirectory(path); - - // If we have no specific parent directory, we consider parent is root (and always exists) - if (parentDirectory.isEmpty() || parentDirectory.equals(S3_FILE_PATH_DELIMITER)) { - return true; - } - - // Check for existence twice, because s3Mock has issues in the tests - return pathExists(parentDirectory); - } - - private String getParentDirectory(String path) { - if (!path.contains(S3_FILE_PATH_DELIMITER)) { - return ""; - } - - // Get the last non-slash character of the string, to find the parent directory - int fromEnd = path.length() - 1; - if (path.endsWith(S3_FILE_PATH_DELIMITER)) { - fromEnd -= 1; - } - return fromEnd > 0 - ? path.substring(0, path.lastIndexOf(S3_FILE_PATH_DELIMITER, fromEnd) + 1) - : ""; - } - /** Ensures path adheres to some rules: -Doesn't start with a leading slash */ String sanitizedPath(String path) { // Trim space from start and end diff --git a/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3BackupRepositoryTest.java b/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3BackupRepositoryTest.java index bd24ac0a7cc..ae09e1ae8fd 100644 --- a/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3BackupRepositoryTest.java +++ b/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3BackupRepositoryTest.java @@ -29,6 +29,8 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.Duration; +import java.util.List; +import java.util.Set; import org.apache.commons.io.file.PathUtils; import org.apache.lucene.codecs.CodecUtil; import org.apache.lucene.store.BufferedIndexInput; @@ -49,6 +51,7 @@ import software.amazon.awssdk.core.sync.ResponseTransformer; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.S3Object; @ThreadLeakFilters( filters = { @@ -97,8 +100,6 @@ public void testURI() throws IOException { "s3:///d/", directoryUri.toString()); - repo.createDirectory(directoryUri); - assertTrue(repo.exists(directoryUri)); directoryUri = repo.createDirectoryURI("d/"); assertEquals( "createDirectoryURI should have a single trailing slash, even if one is provided", @@ -118,15 +119,24 @@ public void testLocalDirectoryFunctions() throws Exception { URI path = new URI("/test/"); repo.createDirectory(path); - assertTrue(repo.exists(path)); - assertEquals(BackupRepository.PathType.DIRECTORY, repo.getPathType(path)); + assertFalse("An empty directory doesn't exist on S3", repo.exists(path)); assertEquals("No files should exist in dir yet", 0, repo.listAll(path).length); URI subDir = new URI("/test/dir/"); repo.createDirectory(subDir); + assertEquals("No files should exist in subdir yet", 0, repo.listAll(subDir).length); + + try (OutputStream os = repo.createOutput(new URI("/test/dir/file"))) { + os.write(1); + } + assertTrue(repo.exists(path)); assertTrue(repo.exists(subDir)); + assertEquals(BackupRepository.PathType.DIRECTORY, repo.getPathType(path)); assertEquals(BackupRepository.PathType.DIRECTORY, repo.getPathType(subDir)); - assertEquals("No files should exist in subdir yet", 0, repo.listAll(subDir).length); + assertEquals( + "No directory marker objects should be written", + List.of("test/dir/file"), + listKeys("test/")); assertEquals( "subDir should now be returned when listing all in parent dir", @@ -139,6 +149,36 @@ public void testLocalDirectoryFunctions() throws Exception { } } + /** + * Older Solr versions wrote a zero-byte marker object (content type {@code + * application/x-directory}) for every directory. Backups containing them must keep working. + */ + @Test + public void testLegacyDirectoryMarkers() throws Exception { + try (S3BackupRepository repo = getRepository()) { + pushDirectoryMarker("legacy/"); + pushDirectoryMarker("legacy/empty/"); + pushDirectoryMarker("legacy/dir/"); + pushObject("legacy/dir/file", "content"); + + URI legacy = new URI("s3:/legacy/"); + assertTrue(repo.exists(legacy)); + assertTrue(repo.exists(repo.resolveDirectory(legacy, "empty"))); + assertEquals(BackupRepository.PathType.DIRECTORY, repo.getPathType(legacy)); + assertEquals( + BackupRepository.PathType.DIRECTORY, + repo.getPathType(repo.resolveDirectory(legacy, "dir"))); + assertEquals( + BackupRepository.PathType.FILE, repo.getPathType(repo.resolve(legacy, "dir", "file"))); + assertEquals(Set.of("empty", "dir"), Set.of(repo.listAll(legacy))); + assertArrayEquals(new String[] {"file"}, repo.listAll(repo.resolveDirectory(legacy, "dir"))); + + repo.deleteDirectory(legacy); + assertFalse(repo.exists(legacy)); + assertEquals("Directory markers should be deleted too", List.of(), listKeys("legacy/")); + } + } + /** Check resolving paths. */ @Test public void testResolve() throws Exception { @@ -348,6 +388,25 @@ private void pushObject(String path, String content) { } } + private void pushDirectoryMarker(String path) { + try (S3Client s3 = s3MockContainer.createS3ClientV2()) { + s3.putObject( + b -> b.bucket(BUCKET_NAME).key(path).contentType("application/x-directory"), + RequestBody.empty()); + } + } + + private List listKeys(String prefix) { + try (S3Client s3 = s3MockContainer.createS3ClientV2()) { + return s3 + .listObjectsV2Paginator(b -> b.bucket(BUCKET_NAME).prefix(prefix)) + .contents() + .stream() + .map(S3Object::key) + .toList(); + } + } + private Path pullObject(String path) throws IOException { try (S3Client s3 = s3MockContainer.createS3ClientV2()) { Path file = Files.createTempFile(temporaryFolder, "junit", null); diff --git a/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3IncrementalBackupTest.java b/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3IncrementalBackupTest.java index 9546012190b..cc3a4474d5c 100644 --- a/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3IncrementalBackupTest.java +++ b/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3IncrementalBackupTest.java @@ -19,14 +19,33 @@ import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakLingering; +import java.util.List; +import java.util.Map; import org.apache.lucene.tests.util.LuceneTestCase; import org.apache.lucene.tests.util.QuickPatchThreadsFilter; import org.apache.solr.SolrIgnoredThreadsFilter; +import org.apache.solr.client.solrj.SolrRequest.METHOD; +import org.apache.solr.client.solrj.SolrRequest.SolrRequestType; +import org.apache.solr.client.solrj.impl.CloudSolrClient; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.request.ConfigSetAdminRequest; +import org.apache.solr.client.solrj.request.GenericSolrRequest; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.apache.solr.client.solrj.response.RequestStatusState; +import org.apache.solr.cloud.AbstractFullDistribZkTestBase; import org.apache.solr.cloud.api.collections.AbstractIncrementalBackupTest; +import org.apache.solr.common.cloud.Replica; +import org.apache.solr.common.cloud.ZkStateReader; +import org.apache.solr.common.params.CoreAdminParams.CoreAdminAction; +import org.apache.solr.common.params.ModifiableSolrParams; +import org.apache.solr.handler.BackupRestoreUtils; import org.apache.solr.util.LogLevel; import org.junit.BeforeClass; import org.junit.ClassRule; +import org.junit.Test; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.S3Object; // Backups do checksum validation against a footer value not present in 'SimpleText' @LuceneTestCase.Nightly @@ -141,4 +160,178 @@ public String getCollectionNamePrefix() { public String getBackupLocation() { return "/"; } + + /** + * Copying a backup to another bucket or prefix with tools such as {@code aws s3 sync} only copies + * the real files, not zero-byte directory marker objects. Such a copy, configset included, must + * still restore. + */ + @Test + public void testRestoreBackupCopiedWithoutDirectoryMarkers() throws Exception { + setTestSuffix("testcopiedwithoutmarkers"); + final String backupCollectionName = getCollectionName(); + final String restoreCollectionName = backupCollectionName + "_restore"; + final String restoreConfigName = "conf1_restored"; + final String backupName = BACKUPNAME_PREFIX + testSuffix; + final String copyLocation = "copied-backups"; + + CloudSolrClient solrClient = cluster.getSolrClient(); + CollectionAdminRequest.createCollection(backupCollectionName, "conf1", NUM_SHARDS, 1) + .process(solrClient); + int numDocs = indexDocs(backupCollectionName, true); + CollectionAdminRequest.backupCollection(backupCollectionName, backupName) + .setLocation(getBackupLocation()) + .setRepositoryName(BACKUP_REPO_NAME) + .processAndWait(solrClient, 100); + + try (S3Client s3 = s3MockContainer.createS3ClientV2()) { + List keys = + s3 + .listObjectsV2Paginator(b -> b.bucket(BUCKET_NAME).prefix(backupName + "/")) + .contents() + .stream() + .map(S3Object::key) + .toList(); + assertTrue( + "Backup should contain the configset", + keys.stream().anyMatch(k -> k.contains("/configs/conf1/"))); + assertFalse( + "Backup should not contain directory marker objects: " + keys, + keys.stream().anyMatch(k -> k.endsWith("/"))); + + for (String key : keys) { + s3.copyObject( + b -> + b.sourceBucket(BUCKET_NAME) + .sourceKey(key) + .destinationBucket(BUCKET_NAME) + .destinationKey(copyLocation + "/" + key)); + } + } + + // Restoring under a new config name makes the restore upload the configset from the backup + CollectionAdminRequest.restoreCollection(restoreCollectionName, backupName) + .setLocation(copyLocation) + .setConfigName(restoreConfigName) + .setRepositoryName(BACKUP_REPO_NAME) + .processAndWait(solrClient, 500); + AbstractFullDistribZkTestBase.waitForRecoveriesToFinish( + restoreCollectionName, ZkStateReader.from(solrClient), false, false, 3); + + assertTrue( + new ConfigSetAdminRequest.List() + .process(solrClient) + .getConfigSets() + .contains(restoreConfigName)); + assertEquals( + numDocs, + solrClient.query(restoreCollectionName, new SolrQuery("*:*")).getResults().getNumFound()); + } + + /** + * S3 can't hold an empty directory, so a backup location doesn't have to be created first: it + * comes into existence with the first file of the backup. Restoring from a location that holds + * nothing still fails. + */ + @Test + public void testBackupToLocationThatDoesNotExistYet() throws Exception { + setTestSuffix("testnewlocation"); + final String backupCollectionName = getCollectionName(); + final String restoreCollectionName = backupCollectionName + "_restore"; + final String backupName = BACKUPNAME_PREFIX + testSuffix; + final String newLocation = "new-location/nested"; + + CloudSolrClient solrClient = cluster.getSolrClient(); + CollectionAdminRequest.createCollection(backupCollectionName, "conf1", NUM_SHARDS, 1) + .process(solrClient); + int numDocs = indexDocs(backupCollectionName, true); + assertEquals( + RequestStatusState.COMPLETED, + CollectionAdminRequest.backupCollection(backupCollectionName, backupName) + .setLocation(newLocation) + .setRepositoryName(BACKUP_REPO_NAME) + .processAndWait(solrClient, 100)); + + assertEquals( + RequestStatusState.COMPLETED, + CollectionAdminRequest.restoreCollection(restoreCollectionName, backupName) + .setLocation(newLocation) + .setRepositoryName(BACKUP_REPO_NAME) + .processAndWait(solrClient, 500)); + AbstractFullDistribZkTestBase.waitForRecoveriesToFinish( + restoreCollectionName, ZkStateReader.from(solrClient), false, false, 3); + assertEquals( + numDocs, + solrClient.query(restoreCollectionName, new SolrQuery("*:*")).getResults().getNumFound()); + + Exception e = + expectThrows( + Exception.class, + () -> + CollectionAdminRequest.restoreCollection(restoreCollectionName + "_2", backupName) + .setLocation("missing-location") + .setRepositoryName(BACKUP_REPO_NAME) + .processAndWait(solrClient, 100)); + assertTrue(e.getMessage(), e.getMessage().contains("does not exist")); + } + + /** + * A core backup in the non-incremental format to a location that does not exist yet. On S3 the + * location only comes into existence with the first file of the backup. + */ + @Test + public void testNonIncrementalCoreBackupToLocationThatDoesNotExistYet() throws Exception { + setTestSuffix("testcorenewlocation"); + final String collectionName = getCollectionName(); + final String backupName = BACKUPNAME_PREFIX + testSuffix; + final String newLocation = "new-core-location/nested"; + + CloudSolrClient solrClient = cluster.getSolrClient(); + CollectionAdminRequest.createCollection(collectionName, "conf1", 1, 1).process(solrClient); + int numDocs = indexDocs(collectionName, true); + Replica replica = + solrClient.getClusterState().getCollection(collectionName).getLeader("shard1"); + + runCoreAdminCommand( + replica, + CoreAdminAction.BACKUPCORE, + Map.of( + "name", backupName, + "repository", BACKUP_REPO_NAME, + "location", newLocation, + "incremental", "false")); + + try (S3Client s3 = s3MockContainer.createS3ClientV2()) { + assertFalse( + "Backup should contain the snapshot's index files", + s3.listObjectsV2( + b -> b.bucket(BUCKET_NAME).prefix(newLocation + "/snapshot." + backupName + "/")) + .contents() + .isEmpty()); + } + + solrClient.deleteByQuery(collectionName, "*:*"); + solrClient.commit(collectionName); + runCoreAdminCommand( + replica, + CoreAdminAction.RESTORECORE, + Map.of( + "name", "snapshot." + backupName, + "repository", BACKUP_REPO_NAME, + "location", newLocation)); + BackupRestoreUtils.verifyDocs(numDocs, solrClient, collectionName); + } + + private void runCoreAdminCommand( + Replica replica, CoreAdminAction action, Map params) throws Exception { + ModifiableSolrParams coreParams = new ModifiableSolrParams(); + coreParams.set("action", action.toString()); + coreParams.set("core", replica.getCoreName()); + params.forEach(coreParams::set); + cluster + .getJetty(replica.getNodeName()) + .getSolrClient() + .request( + new GenericSolrRequest(METHOD.GET, "/admin/cores", SolrRequestType.ADMIN, coreParams)); + } } diff --git a/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3PathsTest.java b/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3PathsTest.java index d314855c3b1..012b564b9e7 100644 --- a/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3PathsTest.java +++ b/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3PathsTest.java @@ -48,6 +48,9 @@ public void testFiles() throws S3Exception { public void testDirectory() throws S3Exception { client.createDirectory("/simple-directory"); + assertFalse("An empty dir does not exist on S3", client.pathExists("/simple-directory/")); + + pushContent("/simple-directory/a-file", "content"); assertFalse("Dir path needs a trailing slash", client.pathExists("/simple-directory")); assertTrue("Dir should exist without a leading slash", client.pathExists("simple-directory/")); assertTrue("Dir should exist with a leading slash", client.pathExists("/simple-directory/")); @@ -163,6 +166,25 @@ public void testDeletePrefix() throws S3Exception { assertTrue(client.pathExists("/my-file2")); } + /** A directory is any key prefix that has objects under it; no marker object is needed. */ + @Test + public void testVirtualDirectoryWithoutMarker() throws S3Exception { + pushContent("/virtual-dir-sibling", "not under the prefix"); + pushContent("/virtual-dir/file1", "file1"); + pushContent("/virtual-dir/sub-dir/file2", "file2"); + + assertTrue( + "A path with objects under it should be considered a virtual directory", + client.isDirectory("/virtual-dir")); + assertTrue( + "A nested path with objects under it should be considered a virtual directory", + client.isDirectory("/virtual-dir/sub-dir")); + assertFalse( + "A path with no objects and no marker should not be a directory", + client.isDirectory("/virtual-dir/empty-sub-dir")); + assertFalse("A file is not a directory", client.isDirectory("/virtual-dir/file1")); + } + /** Check listing objects of a directory. */ @Test public void testListDir() throws S3Exception { diff --git a/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3ReadWriteTest.java b/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3ReadWriteTest.java index 3574587b4fa..9ff6fa17858 100644 --- a/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3ReadWriteTest.java +++ b/solr/modules/s3-repository/src/test/org/apache/solr/s3/S3ReadWriteTest.java @@ -90,6 +90,7 @@ public void testLength() throws Exception { @Test public void testDirectoryLength() throws Exception { client.createDirectory("/directory"); + pushContent("/directory/file", "child"); S3Exception exception = assertThrows( diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/backup-restore.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/backup-restore.adoc index e243e15a7b3..cee577412b4 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/backup-restore.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/backup-restore.adoc @@ -727,14 +727,18 @@ When using the Backup & Restore Collections API Calls, the **`location`** option + All the above options will resolve to the same directory in your S3 Bucket: `dir/in/bucket`. -Pre-Creation:: -The location must already exist within your S3 bucket before you can perform any backup operations with that location. -+ -**Please note that the directory you create in S3 cannot begin with a `/`, as `locations` are stripped of any `/` prefix (shown in `Location Format`).** +Location Creation:: +The location doesn't need to exist prior to backup; it is created automatically when the first file is written. +However, restoring, listing, or deleting backups requires an existing location with at least one object. Empty Location:: If you do not want to use a sub-directory within your bucket to store your backup, you can use any of the following location options: `/`, `s3:/`, `s3://`. However the location option is mandatory and you will receive an error when trying to perform backup operations without it. + +Directory Markers:: +Backups can be copied between buckets or locations with standard S3 tools, such as `aws s3 sync`, and still be restored. +This works because Solr treats a directory as nothing more than the common key prefix of the files stored under it, rather than writing a zero-byte "directory marker" object for it. +Backups made by older Solr versions, which do contain marker objects, remain fully restorable and deletable. ==== An example configuration to enable S3 backups and restore can be seen below: diff --git a/solr/test-framework/src/java/org/apache/solr/cloud/api/collections/AbstractBackupRepositoryTest.java b/solr/test-framework/src/java/org/apache/solr/cloud/api/collections/AbstractBackupRepositoryTest.java index af8f5dbc3ce..eebdab230ef 100644 --- a/solr/test-framework/src/java/org/apache/solr/cloud/api/collections/AbstractBackupRepositoryTest.java +++ b/solr/test-framework/src/java/org/apache/solr/cloud/api/collections/AbstractBackupRepositoryTest.java @@ -111,7 +111,7 @@ public void testCanDetermineWhetherFilesAndDirectoriesExist() throws Exception { repo.createDirectory(nonEmptyDirUri); addFile(repo, nestedFileUri); - assertTrue(repo.exists(emptyDirUri)); + assertEquals(repo.supportsEmptyDirectories(), repo.exists(emptyDirUri)); assertTrue(repo.exists(nonEmptyDirUri)); assertTrue(repo.exists(nestedFileUri)); final URI nonexistedDirUri = repo.resolve(getBaseUri(), "nonexistentDir/"); @@ -129,7 +129,9 @@ public void testCanDistinguishBetweenFilesAndDirectories() throws Exception { repo.createDirectory(nonEmptyDirUri); addFile(repo, nestedFileUri); - assertEquals(BackupRepository.PathType.DIRECTORY, repo.getPathType(emptyDirUri)); + if (repo.supportsEmptyDirectories()) { + assertEquals(BackupRepository.PathType.DIRECTORY, repo.getPathType(emptyDirUri)); + } assertEquals(BackupRepository.PathType.DIRECTORY, repo.getPathType(nonEmptyDirUri)); assertEquals(BackupRepository.PathType.FILE, repo.getPathType(nestedFileUri)); } @@ -186,6 +188,8 @@ public void testCanDeleteEmptyOrFullDirectories() throws Exception { repo.createDirectory(level2DeeplyNestedUri); repo.createDirectory(level3DeeplyNestedUri); repo.createDirectory(level4DeeplyNestedUri); + // Keep nest2 (and therefore nest1) non-empty once nest3 is deleted + keepNonEmptyIfNeeded(repo, level2DeeplyNestedUri, level4DeeplyNestedUri); repo.deleteDirectory(level3DeeplyNestedUri); assertTrue(repo.exists(level1DeeplyNestedUri)); assertTrue(repo.exists(level2DeeplyNestedUri)); @@ -241,6 +245,8 @@ public void testCanListFullOrEmptyDirectories() throws Exception { repo.createDirectory(otherDir1Uri); repo.createDirectory(otherDir2Uri); repo.createDirectory(otherDir3Uri); + // Without empty directories, otherDir1 and otherDir2 only show up once they hold a file + keepNonEmptyIfNeeded(repo, otherDir1Uri, otherDir2Uri); addFile(repo, file1Uri); addFile(repo, file2Uri); @@ -251,7 +257,7 @@ public void testCanListFullOrEmptyDirectories() throws Exception { assertTrue(rootChildren.contains("otherDir3")); final String[] otherDir2Children = repo.listAll(otherDir2Uri); - assertEquals(0, otherDir2Children.length); + assertEquals(repo.supportsEmptyDirectories() ? 0 : 1, otherDir2Children.length); final List otherDir3Children = List.of(repo.listAll(otherDir3Uri)); assertEquals(2, otherDir3Children.size()); @@ -391,6 +397,20 @@ private void addFile(BackupRepository repo, URI file) throws IOException { } } + /** + * On a repository that can't hold empty directories, seeds a file under each given directory so + * it (and, transitively, its ancestors) stays non-empty for the rest of the test. No-op when the + * repository supports real empty directories. + */ + private void keepNonEmptyIfNeeded(BackupRepository repo, URI... dirs) throws IOException { + if (repo.supportsEmptyDirectories()) { + return; + } + for (URI dir : dirs) { + addFile(repo, repo.resolve(dir, "file.txt")); + } + } + /** * Test implementation of a {@link DelegatingBackupRepository} that disables the checksum * verification on its delegate {@link BackupRepository}.