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}.