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

epugh pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_10x by this push:
     new bc9c1791c67 SOLR-18250: GCSBackupRepository.copyIndexFileTo must not 
swallow failures (#4726)
bc9c1791c67 is described below

commit bc9c1791c67382eeb2567404c9218a3f67211f24
Author: Prithvi S <[email protected]>
AuthorDate: Mon Aug 24 19:08:24 2026 +0530

    SOLR-18250: GCSBackupRepository.copyIndexFileTo must not swallow failures 
(#4726)
    
    Signed-off-by: prithvi <[email protected]>
    (cherry picked from commit 7c02750192a494602b08c95be02828b6016052c5)
---
 changelog/unreleased/gcs-copyIndexFileTo-fix.yml   |  10 ++
 .../org/apache/solr/gcs/GCSBackupRepository.java   |  36 ++--
 .../apache/solr/gcs/GCSBackupRepositoryTest.java   | 188 +++++++++++++++++++++
 3 files changed, 217 insertions(+), 17 deletions(-)

diff --git a/changelog/unreleased/gcs-copyIndexFileTo-fix.yml 
b/changelog/unreleased/gcs-copyIndexFileTo-fix.yml
new file mode 100644
index 00000000000..9714825d5fb
--- /dev/null
+++ b/changelog/unreleased/gcs-copyIndexFileTo-fix.yml
@@ -0,0 +1,10 @@
+title: >
+  Fixed GCS backup restores silently swallowing failures and stopping early on 
zero-byte reads (SOLR-18250).
+type: fixed
+authors:
+  - name: Prithvi S
+links:
+  - name: SOLR-18250
+    url: https://issues.apache.org/jira/browse/SOLR-18250
+  - name: PR#4726
+    url: https://github.com/apache/solr/pull/4726
diff --git 
a/solr/modules/gcs-repository/src/java/org/apache/solr/gcs/GCSBackupRepository.java
 
b/solr/modules/gcs-repository/src/java/org/apache/solr/gcs/GCSBackupRepository.java
index 3d176a5c3d2..ae767aa2e43 100644
--- 
a/solr/modules/gcs-repository/src/java/org/apache/solr/gcs/GCSBackupRepository.java
+++ 
b/solr/modules/gcs-repository/src/java/org/apache/solr/gcs/GCSBackupRepository.java
@@ -361,24 +361,26 @@ public class GCSBackupRepository extends 
AbstractBackupRepository {
   public void copyIndexFileTo(
       URI sourceRepo, String sourceFileName, Directory dest, String 
destFileName)
       throws IOException {
-    try {
-      String blobName = sourceRepo.toString();
-      blobName = appendTrailingSeparatorIfNecessary(blobName);
-      blobName += sourceFileName;
-      final BlobId blobId = BlobId.of(bucketName, blobName);
-      try (final ReadChannel readChannel = storage.reader(blobId);
-          IndexOutput output =
-              dest.createOutput(destFileName, 
DirectoryFactory.IOCONTEXT_NO_CACHE)) {
-        ByteBuffer buffer = ByteBuffer.allocate(readBufferSizeBytes);
-        while (readChannel.read(buffer) > 0) {
-          buffer.flip();
-          byte[] arr = buffer.array();
-          output.writeBytes(arr, buffer.position(), buffer.limit() - 
buffer.position());
-          buffer.clear();
-        }
+    String blobName = sourceRepo.toString();
+    blobName = appendTrailingSeparatorIfNecessary(blobName);
+    blobName += sourceFileName;
+    final BlobId blobId = BlobId.of(bucketName, blobName);
+    try (final ReadChannel readChannel = storage.reader(blobId);
+        IndexOutput output = dest.createOutput(destFileName, 
DirectoryFactory.IOCONTEXT_NO_CACHE)) {
+      ByteBuffer buffer = ByteBuffer.allocate(readBufferSizeBytes);
+      while (readChannel.read(buffer) != -1) {
+        buffer.flip();
+        byte[] arr = buffer.array();
+        output.writeBytes(arr, buffer.position(), buffer.limit() - 
buffer.position());
+        buffer.clear();
       }
-    } catch (Exception e) {
-      log.info("Here's an exception e", e);
+    } catch (IOException e) {
+      log.error("Failed to copy index file from GCS: {}/{}", bucketName, 
blobName, e);
+      throw e;
+    } catch (RuntimeException e) {
+      log.error("Failed to copy index file from GCS: {}/{}", bucketName, 
blobName, e);
+      throw new IOException(
+          "Failed to copy index file from GCS: " + bucketName + "/" + 
blobName, e);
     }
   }
 
diff --git 
a/solr/modules/gcs-repository/src/test/org/apache/solr/gcs/GCSBackupRepositoryTest.java
 
b/solr/modules/gcs-repository/src/test/org/apache/solr/gcs/GCSBackupRepositoryTest.java
index 10d2acb2bd6..e4a2e084445 100644
--- 
a/solr/modules/gcs-repository/src/test/org/apache/solr/gcs/GCSBackupRepositoryTest.java
+++ 
b/solr/modules/gcs-repository/src/test/org/apache/solr/gcs/GCSBackupRepositoryTest.java
@@ -21,10 +21,26 @@ import static 
org.apache.solr.common.params.CoreAdminParams.BACKUP_LOCATION;
 import static org.apache.solr.gcs.GCSConfigParser.GCS_BUCKET_ENV_VAR_NAME;
 import static org.apache.solr.gcs.GCSConfigParser.GCS_CREDENTIAL_ENV_VAR_NAME;
 
+import com.google.cloud.ReadChannel;
+import com.google.cloud.storage.BlobId;
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.Storage;
+import com.google.cloud.storage.StorageException;
+import com.google.cloud.storage.contrib.nio.testing.LocalStorageHelper;
+import java.io.IOException;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.nio.ByteBuffer;
 import java.util.HashMap;
 import java.util.Map;
+import org.apache.lucene.store.ByteBuffersDirectory;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.IOContext;
+import org.apache.lucene.store.IndexInput;
 import org.apache.solr.cloud.api.collections.AbstractBackupRepositoryTest;
 import org.apache.solr.common.util.NamedList;
 import org.apache.solr.core.backup.repository.BackupRepository;
@@ -77,4 +93,176 @@ public class GCSBackupRepositoryTest extends 
AbstractBackupRepositoryTest {
 
     gcsBackupRepository.init(new NamedList<>(config));
   }
+
+  @Test
+  public void testCopyIndexFileToPropagatesReadFailures() throws Exception {
+    Storage failingStorage = createFailingStorage();
+    GCSBackupRepository repo = createRepositoryWithStorage(failingStorage);
+
+    try (Directory dest = new ByteBuffersDirectory()) {
+      URI sourceDir = repo.resolve(getBaseUri(), "backup");
+      IOException thrown =
+          expectThrows(
+              IOException.class,
+              () -> repo.copyIndexFileTo(sourceDir, "any.dat", dest, 
"dest.dat"));
+      assertTrue(thrown.getMessage().contains("Failed to copy index file from 
GCS"));
+      assertNotNull(thrown.getCause());
+      assertTrue(thrown.getCause() instanceof StorageException);
+      assertEquals("simulated GCS read failure", 
thrown.getCause().getMessage());
+    }
+  }
+
+  @Test
+  public void testCopyIndexFileToHandlesZeroByteReads() throws Exception {
+    Storage realStorage = LocalStorageHelper.customOptions(false).getService();
+    byte[] data = new byte[100];
+    random().nextBytes(data);
+    // "solrBackupsBucket" matches GCSConfigParser.DEFAULT_GCS_BUCKET_VALUE
+    String bucketName = "solrBackupsBucket";
+
+    GCSBackupRepository repo = createRepositoryWithStorage(realStorage);
+    URI sourceDir = repo.resolve(getBaseUri(), "backup");
+    BlobId blobId = BlobId.of(bucketName, sourceDir + "/source.dat");
+    realStorage.create(BlobInfo.newBuilder(blobId).build(), data);
+
+    Storage zeroReturningStorage = createZeroReturningStorage(realStorage);
+    GCSBackupRepository proxyRepo = 
createRepositoryWithStorage(zeroReturningStorage);
+
+    try (Directory dest = new ByteBuffersDirectory()) {
+      proxyRepo.copyIndexFileTo(sourceDir, "source.dat", dest, "dest.dat");
+      try (IndexInput in = dest.openInput("dest.dat", IOContext.DEFAULT)) {
+        assertEquals(data.length, in.length());
+        byte[] read = new byte[data.length];
+        in.readBytes(read, 0, data.length);
+        assertArrayEquals(data, read);
+      }
+    }
+  }
+
+  @Test
+  public void testCopyIndexFileToCopiesFile() throws Exception {
+    Storage realStorage = LocalStorageHelper.customOptions(false).getService();
+    byte[] data = new byte[100];
+    random().nextBytes(data);
+    // "solrBackupsBucket" matches GCSConfigParser.DEFAULT_GCS_BUCKET_VALUE
+    String bucketName = "solrBackupsBucket";
+
+    GCSBackupRepository repo = createRepositoryWithStorage(realStorage);
+    URI sourceDir = repo.resolve(getBaseUri(), "backup");
+    BlobId blobId = BlobId.of(bucketName, sourceDir + "/source.dat");
+    realStorage.create(BlobInfo.newBuilder(blobId).build(), data);
+
+    try (Directory dest = new ByteBuffersDirectory()) {
+      repo.copyIndexFileTo(sourceDir, "source.dat", dest, "dest.dat");
+      try (IndexInput in = dest.openInput("dest.dat", IOContext.DEFAULT)) {
+        assertEquals(data.length, in.length());
+        byte[] read = new byte[data.length];
+        in.readBytes(read, 0, data.length);
+        assertArrayEquals(data, read);
+      }
+    }
+  }
+
+  /** Storage proxy that fails on {@code reader} so we can assert copy errors 
are propagated. */
+  private static Storage createFailingStorage() {
+    Storage delegate = LocalStorageHelper.customOptions(false).getService();
+    return (Storage)
+        Proxy.newProxyInstance(
+            Storage.class.getClassLoader(),
+            new Class<?>[] {Storage.class},
+            (proxy, method, args) -> {
+              if ("reader".equals(method.getName())) {
+                throw new StorageException(0, "simulated GCS read failure");
+              }
+              return invokeAndUnwrap(method, delegate, args);
+            });
+  }
+
+  /**
+   * Storage proxy whose {@code reader} first returns a zero-byte {@link 
ReadChannel}, so we can
+   * assert that {@code copyIndexFileTo} retries instead of treating 0 as EOF.
+   */
+  private static Storage createZeroReturningStorage(Storage delegate) {
+    return (Storage)
+        Proxy.newProxyInstance(
+            Storage.class.getClassLoader(),
+            new Class<?>[] {Storage.class},
+            (proxy, method, args) -> {
+              if ("reader".equals(method.getName())
+                  && args != null
+                  && args.length == 1
+                  && args[0] instanceof BlobId) {
+                ReadChannel realChannel = (ReadChannel) 
invokeAndUnwrap(method, delegate, args);
+                return createZeroFirstReadChannel(realChannel);
+              }
+              return invokeAndUnwrap(method, delegate, args);
+            });
+  }
+
+  /**
+   * {@link ReadChannel} that returns 0 on the first {@code read} (a 
short/empty read, not EOF),
+   * then delegates later reads to {@code delegate}. Used to reproduce the 
restore-truncation bug
+   * where a 0-byte read was treated as end-of-stream.
+   */
+  private static ReadChannel createZeroFirstReadChannel(ReadChannel delegate) {
+    return (ReadChannel)
+        Proxy.newProxyInstance(
+            ReadChannel.class.getClassLoader(),
+            new Class<?>[] {ReadChannel.class},
+            new InvocationHandler() {
+              private boolean returnedZero = false;
+
+              @Override
+              public Object invoke(Object proxy, Method method, Object[] args) 
throws Throwable {
+                if ("read".equals(method.getName())
+                    && args != null
+                    && args.length == 1
+                    && args[0] instanceof ByteBuffer) {
+                  if (!returnedZero) {
+                    returnedZero = true;
+                    return 0;
+                  }
+                }
+                return invokeAndUnwrap(method, delegate, args);
+              }
+            });
+  }
+
+  /**
+   * Forwards a JDK proxy call to {@code target}. {@link Method#invoke} wraps 
checked exceptions in
+   * {@link InvocationTargetException}; unwrap so tests and {@code 
copyIndexFileTo} see the real GCS
+   * / channel exception instead of a reflection wrapper.
+   */
+  private static Object invokeAndUnwrap(Method method, Object target, Object[] 
args)
+      throws Throwable {
+    try {
+      return method.invoke(target, args);
+    } catch (InvocationTargetException e) {
+      throw e.getCause();
+    }
+  }
+
+  private GCSBackupRepository createRepositoryWithStorage(Storage storage) {
+    TestGCSBackupRepository repo = new TestGCSBackupRepository(storage);
+    repo.init(getBaseBackupRepositoryConfiguration());
+    return repo;
+  }
+
+  /**
+   * Test-only repository that injects a given {@link Storage} instead of 
creating a real GCS
+   * client. Lets tests simulate failures and unusual read behavior without 
talking to GCS.
+   */
+  private static class TestGCSBackupRepository extends GCSBackupRepository {
+    private final Storage testStorage;
+
+    TestGCSBackupRepository(Storage testStorage) {
+      this.testStorage = testStorage;
+    }
+
+    @Override
+    protected Storage initStorage() {
+      this.storage = testStorage;
+      return testStorage;
+    }
+  }
 }

Reply via email to