Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
64619f8
parallelize backups so backups to s3 don't take ages
samuelverstraete Jan 6, 2026
34a9000
parallelize the restore too
samuelverstraete Jan 6, 2026
f83b6e8
make sure gradlew check runs properly, add to documentation, default …
samuelverstraete Jan 6, 2026
3e17ce5
changelog and var renaming
samuelverstraete Jan 7, 2026
a19ebce
modified the changeog and fixed the system property casing
samuelverstraete Jan 7, 2026
3fcf874
this should have been in the task from the get go, did not realize it…
samuelverstraete Jan 7, 2026
23d5561
fix a compile warning The type `ThreadPoolExecutor.CallerRunsPolicy` …
samuelverstraete Jan 7, 2026
5ed0f17
fix changelog errors
samuelverstraete Jan 7, 2026
a417d32
Simplify error handling in backup/restore parallel file operations
samuelverstraete Mar 11, 2026
d8bb9b4
Convert backup/restore executor management to shared static pools
samuelverstraete May 6, 2026
a893b64
Update backup/restore parallelism documentation with revised guidance
samuelverstraete May 6, 2026
f7dd1a9
Ignore static backup/restore thread pool names in test framework
samuelverstraete May 6, 2026
8e97e4d
Address reviewer feedback on backup/restore executor naming and sizing
samuelverstraete May 7, 2026
da2103c
Simplify futures wait loop by flipping try/for nesting
samuelverstraete May 7, 2026
505691f
Merge branch 'refs/heads/main' into fork/elangelo/parallelizebackups
dsmiley Jun 9, 2026
67ccadf
Manage the executors in ObjectCache.
dsmiley Jun 9, 2026
9276aa7
Rename file.
dsmiley Jun 9, 2026
f35854c
gradlew tidy
samuelverstraete Jun 10, 2026
2a874be
whoops; use "BackupUploadExecutor" name
dsmiley Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/parallelizebackups.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
title: Parallelize Backup and Restore File Operations
type: changed
authors:
- name: Samuel Verstraete
github: elangelo

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog author metadata uses a github field, but this repository’s changelog format documentation uses nick (optionally with url) under authors. Using an unexpected key may fail changelog validation or omit author info; please switch github: elangelo to nick: elangelo (and add url if desired).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


183 changes: 154 additions & 29 deletions solr/core/src/java/org/apache/solr/handler/IncrementalShardBackup.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,27 @@
import java.lang.invoke.MethodHandles;
import java.net.URI;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.math3.util.Precision;
import org.apache.lucene.index.IndexCommit;
import org.apache.lucene.store.Directory;
import org.apache.solr.client.api.model.SolrJerseyResponse;
import org.apache.solr.cloud.CloudDescriptor;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.EnvUtils;
import org.apache.solr.common.util.ExecutorUtil;
import org.apache.solr.common.util.SolrNamedThreadFactory;
import org.apache.solr.core.DirectoryFactory;
import org.apache.solr.core.IndexDeletionPolicyWrapper;
import org.apache.solr.core.SolrCore;
Expand All @@ -52,6 +64,15 @@
*/
public class IncrementalShardBackup {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

/**
* Maximum number of files to upload in parallel during backup. Can be configured via the system
* property {@code solr.backup.maxparalleluploads} or environment variable {@code
* SOLR_BACKUP_MAXPARALLELUPLOADS}.
*/
private static final int DEFAULT_MAX_PARALLEL_UPLOADS =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should just be MAX_PARALLEL_UPLOADS and drop the default prefix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

EnvUtils.getPropertyAsInteger("solr.backup.maxparalleluploads", 1);

private SolrCore solrCore;

private BackupFilePaths incBackupFiles;
Expand Down Expand Up @@ -154,8 +175,8 @@ protected IncrementalShardSnapshotResponse backup(final IndexCommit indexCommit)
solrCore.getSolrConfig().indexConfig.lockType);
try {
BackupStats stats = incrementalCopy(files, dir);
details.indexFileCount = stats.fileCount;
details.uploadedIndexFileCount = stats.uploadedFileCount;
details.indexFileCount = stats.fileCount.get();
Comment thread
epugh marked this conversation as resolved.
details.uploadedIndexFileCount = stats.uploadedFileCount.get();
details.indexSizeMB = stats.getIndexSizeMB();
details.uploadedIndexFileMB = stats.getTotalUploadedMB();
} finally {
Expand Down Expand Up @@ -191,55 +212,159 @@ private BackupStats incrementalCopy(Collection<String> indexFiles, Directory dir
URI indexDir = incBackupFiles.getIndexDir();
BackupStats backupStats = new BackupStats();

for (String fileName : indexFiles) {
Optional<ShardBackupMetadata.BackedFile> opBackedFile = oldBackupPoint.getFile(fileName);
Checksum originalFileCS = backupRepo.checksum(dir, fileName);

if (opBackedFile.isPresent()) {
ShardBackupMetadata.BackedFile backedFile = opBackedFile.get();
Checksum existedFileCS = backedFile.fileChecksum;
if (existedFileCS.equals(originalFileCS)) {
currentBackupPoint.addBackedFile(opBackedFile.get());
backupStats.skippedUploadingFile(existedFileCS);
continue;
// Only use an executor for parallel uploads when parallelism > 1
// When set to 1, run synchronously to avoid thread-local state issues with CallerRunsPolicy
int maxParallelUploads = DEFAULT_MAX_PARALLEL_UPLOADS;
Comment thread
dsmiley marked this conversation as resolved.
Outdated
ExecutorService executor =
maxParallelUploads > 1
? new ExecutorUtil.MDCAwareThreadPoolExecutor(
0,
maxParallelUploads,
60L,
TimeUnit.SECONDS,
new SynchronousQueue<>(),
new SolrNamedThreadFactory("IncrementalBackup"),
Comment thread
dsmiley marked this conversation as resolved.
Outdated
new ExecutorUtil.MDCAwareThreadPoolExecutor.CallerRunsPolicy())
: null;

List<Future<?>> uploadFutures = new ArrayList<>();

try {

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation queues a Future for every index file and holds them in uploadFutures until the end. For large indexes this can create significant memory overhead and delays error reporting. Consider processing completed tasks as they finish (e.g., ExecutorCompletionService) and/or limiting in-flight submissions to maxParallelUploads.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this really doesn't hold. we need to wait for all futures anyway, so storing them in a list is what we need to do here

for (String fileName : indexFiles) {
Optional<ShardBackupMetadata.BackedFile> opBackedFile = oldBackupPoint.getFile(fileName);
Checksum originalFileCS = backupRepo.checksum(dir, fileName);

if (opBackedFile.isPresent()) {
ShardBackupMetadata.BackedFile backedFile = opBackedFile.get();
Checksum existedFileCS = backedFile.fileChecksum;
if (existedFileCS.equals(originalFileCS)) {
synchronized (currentBackupPoint) {
currentBackupPoint.addBackedFile(opBackedFile.get());
}
backupStats.skippedUploadingFile(existedFileCS);
continue;
}
}

// Capture variables for lambda
final String fileNameFinal = fileName;
final Checksum originalFileCSFinal = originalFileCS;

Runnable uploadTask =
() -> {
try {
String backedFileName = UUID.randomUUID().toString();
backupRepo.copyIndexFileFrom(dir, fileNameFinal, indexDir, backedFileName);

synchronized (currentBackupPoint) {
currentBackupPoint.addBackedFile(
backedFileName, fileNameFinal, originalFileCSFinal);
}
backupStats.uploadedFile(originalFileCSFinal);
} catch (IOException e) {
throw new RuntimeException("Failed to upload file: " + fileNameFinal, e);
}
};

if (executor != null) {
uploadFutures.add(executor.submit(uploadTask));
} else {
// Run synchronously when parallelism is 1
try {
uploadTask.run();
} catch (RuntimeException e) {
if (e.getCause() instanceof IOException) {
throw (IOException) e.getCause();
}
throw e;

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the synchronous path, rethrowing only e.getCause() (when it’s an IOException) loses the wrapper message that includes the filename ("Failed to process file: ..."). Preserve that per-file context when propagating errors so backup failures are diagnosable.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

}
}
}

String backedFileName = UUID.randomUUID().toString();
backupRepo.copyIndexFileFrom(dir, fileName, indexDir, backedFileName);
// Wait for all uploads to complete and collect any errors (only if using executor)
if (executor != null) {
// We need to wait for ALL futures before throwing, otherwise we might exit
// before all successfully uploaded files are added to currentBackupPoint
Throwable firstError = null;
for (Future<?> future : uploadFutures) {
try {
future.get();
} catch (ExecutionException e) {
if (firstError == null) {
Throwable cause = e.getCause();
// Unwrap RuntimeExceptions that wrap the original IOException
if (cause instanceof RuntimeException && cause.getCause() != null) {
firstError = cause.getCause();
} else {

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the parallel join logic, unwrapping RuntimeException to cause.getCause() can discard the wrapper message that includes the filename. Preserve the wrapper message (or re-wrap the underlying IOException with file context) when surfacing the first failure from future.get().

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

firstError = cause;
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (firstError == null) {
firstError = e;
}
}
}

currentBackupPoint.addBackedFile(backedFileName, fileName, originalFileCS);
backupStats.uploadedFile(originalFileCS);
// Now throw the first error we encountered, if any
if (firstError != null) {
if (firstError instanceof Error) {
// Rethrow Errors (like OutOfMemoryError) - don't try to recover
throw (Error) firstError;
} else if (firstError instanceof IOException) {
throw (IOException) firstError;
} else if (firstError instanceof RuntimeException) {
throw (RuntimeException) firstError;
} else if (firstError instanceof InterruptedException) {
throw new IOException("Backup interrupted", firstError);
} else {
throw new IOException("Error during parallel backup upload", firstError);
}
}
}
} finally {
Comment thread
elangelo marked this conversation as resolved.
Outdated
if (executor != null) {
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}

currentBackupPoint.store(backupRepo, incBackupFiles.getShardBackupMetadataDir(), shardBackupId);
return backupStats;
}

private static class BackupStats {
private int fileCount;
private int uploadedFileCount;
private long indexSize;
private long totalUploadedBytes;
private final AtomicInteger fileCount = new AtomicInteger();
private final AtomicInteger uploadedFileCount = new AtomicInteger();
private final AtomicLong indexSize = new AtomicLong();
private final AtomicLong totalUploadedBytes = new AtomicLong();

public void uploadedFile(Checksum file) {
fileCount++;
uploadedFileCount++;
indexSize += file.size;
totalUploadedBytes += file.size;
fileCount.incrementAndGet();
uploadedFileCount.incrementAndGet();
indexSize.addAndGet(file.size);
totalUploadedBytes.addAndGet(file.size);
}

public void skippedUploadingFile(Checksum existedFile) {
fileCount++;
indexSize += existedFile.size;
fileCount.incrementAndGet();
indexSize.addAndGet(existedFile.size);
}

public double getIndexSizeMB() {
return Precision.round(indexSize / (1024.0 * 1024), 3);
return Precision.round(indexSize.get() / (1024.0 * 1024), 3);
}

public double getTotalUploadedMB() {
return Precision.round(totalUploadedBytes / (1024.0 * 1024), 3);
return Precision.round(totalUploadedBytes.get() / (1024.0 * 1024), 3);
}
}

Expand Down
Loading
Loading