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

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

commit 47f0eea91bb404f470a4e860baaaf69478fac016
Author: Shubham Ranjan <[email protected]>
AuthorDate: Wed Jun 3 22:40:54 2026 +0530

    SOLR-18098: Fix replication failure for files with exact MB sizes (#4205)
---
 .../SOLR-18098-fix-replication-exact-mb-sizes.yml  |   7 +
 .../solr/handler/admin/api/ReplicationAPIBase.java |   7 +
 .../handler/IndexFetcherPacketProtocolTest.java    | 399 +++++++++++++++++++++
 3 files changed, 413 insertions(+)

diff --git a/changelog/unreleased/SOLR-18098-fix-replication-exact-mb-sizes.yml 
b/changelog/unreleased/SOLR-18098-fix-replication-exact-mb-sizes.yml
new file mode 100644
index 00000000000..9729678a651
--- /dev/null
+++ b/changelog/unreleased/SOLR-18098-fix-replication-exact-mb-sizes.yml
@@ -0,0 +1,7 @@
+title: Fix replication failure for files with exact MB sizes
+type: fixed
+authors:
+  - name: Shubham Ranjan
+links:
+  - name: SOLR-18098
+    url: https://issues.apache.org/jira/browse/SOLR-18098
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
index 3e627b35d72..132448d1c27 100644
--- 
a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
+++ 
b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
@@ -393,6 +393,13 @@ public abstract class ReplicationAPIBase extends 
JerseyResource {
         while (true) {
           offset = offset == -1 ? 0 : offset;
           int read = (int) Math.min(buf.length, filelen - offset);
+
+          if (read <= 0) {
+            writeNothingAndFlush();
+            fos.close();
+            break;
+          }
+
           in.readBytes(buf, 0, read);
 
           fos.writeInt(read);
diff --git 
a/solr/core/src/test/org/apache/solr/handler/IndexFetcherPacketProtocolTest.java
 
b/solr/core/src/test/org/apache/solr/handler/IndexFetcherPacketProtocolTest.java
new file mode 100644
index 00000000000..050994e98d1
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/IndexFetcherPacketProtocolTest.java
@@ -0,0 +1,399 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.handler;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.IOContext;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.FastInputStream;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.DirectoryFactory;
+import org.apache.solr.handler.admin.api.CoreReplication;
+import org.apache.solr.handler.admin.api.ReplicationAPIBase;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.response.SolrQueryResponse;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Tests the replication packet protocol between DirectoryFileStream (sender) 
and
+ * IndexFetcher.FileFetcher.fetchPackets (receiver).
+ *
+ * <p>The packet protocol is:
+ *
+ * <ul>
+ *   <li>Data packet: {@code int(size) + long(checksum) + byte[size]}
+ *   <li>EOF marker: {@code int(0)} alone (no checksum)
+ * </ul>
+ *
+ * <p>These tests verify correct handling of:
+ *
+ * <ul>
+ *   <li>Files that are exact multiples of PACKET_SZ (1 MB) - sender must not 
produce spurious
+ *       zero-length data packets
+ *   <li>Files of arbitrary sizes - boundary conditions and edge cases
+ *   <li>Checksum verification and error handling
+ * </ul>
+ */
+public class IndexFetcherPacketProtocolTest extends SolrTestCaseJ4 {
+
+  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final int PACKET_SZ = ReplicationAPIBase.PACKET_SZ;
+  private static final int NO_CONTENT = 1;
+
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    System.setProperty("solr.disable.allowUrls", "true");
+    initCore("solrconfig.xml", "schema.xml");
+  }
+
+  @AfterClass
+  public static void afterClass() throws Exception {
+    System.clearProperty("solr.disable.allowUrls");
+  }
+
+  // Tests for files that are exact multiples of PACKET_SZ
+  @Test
+  public void testExactlyOnePacketSize() throws Exception {
+    assertFetchPacketsSuccess(PACKET_SZ, "test-1mb.bin");
+  }
+
+  @Test
+  public void testExactlyTwoPacketSizes() throws Exception {
+    assertFetchPacketsSuccess(2 * PACKET_SZ, "test-2mb.bin");
+  }
+
+  @Test
+  public void testExactlyThreePacketSizes() throws Exception {
+    assertFetchPacketsSuccess(3 * PACKET_SZ, "test-3mb.bin");
+  }
+
+  @Test
+  public void testExactly63PacketSizes() throws Exception {
+    assertFetchPacketsSuccess(63 * PACKET_SZ, "test-63mb.bin");
+  }
+
+  // Tests for files that are not exact multiples of PACKET_SZ
+  @Test
+  public void testEmptyFile() throws Exception {
+    assertFetchPacketsSuccess(0, "test-empty.bin");
+  }
+
+  @Test
+  public void testSingleByte() throws Exception {
+    assertFetchPacketsSuccess(1, "test-1byte.bin");
+  }
+
+  @Test
+  public void testSmallFile100Bytes() throws Exception {
+    assertFetchPacketsSuccess(100, "test-100bytes.bin");
+  }
+
+  @Test
+  public void testSmallFile100KB() throws Exception {
+    assertFetchPacketsSuccess(100 * 1024, "test-100kb.bin");
+  }
+
+  @Test
+  public void testHalfPacketSize() throws Exception {
+    assertFetchPacketsSuccess(PACKET_SZ / 2, "test-512kb.bin");
+  }
+
+  @Test
+  public void testJustUnderOnePacketSize() throws Exception {
+    assertFetchPacketsSuccess(PACKET_SZ - 1, "test-under-1mb.bin");
+  }
+
+  @Test
+  public void testJustOverOnePacketSize() throws Exception {
+    assertFetchPacketsSuccess(PACKET_SZ + 1, "test-over-1mb.bin");
+  }
+
+  @Test
+  public void testOneAndHalfPacketSizes() throws Exception {
+    assertFetchPacketsSuccess(PACKET_SZ + PACKET_SZ / 2, "test-1.5mb.bin");
+  }
+
+  @Test
+  public void testJustUnderTwoPacketSizes() throws Exception {
+    assertFetchPacketsSuccess(2 * PACKET_SZ - 1, "test-under-2mb.bin");
+  }
+
+  @Test
+  public void testJustOverTwoPacketSizes() throws Exception {
+    assertFetchPacketsSuccess(2 * PACKET_SZ + 1, "test-over-2mb.bin");
+  }
+
+  @Test
+  public void testArbitrarySize() throws Exception {
+    assertFetchPacketsSuccess(PACKET_SZ + 500000, "test-arbitrary.bin");
+  }
+
+  // Additional tests for comprehensive coverage
+
+  @Test
+  public void testMultipleExactPacketSizeFiles() throws Exception {
+    // Ensures exact-multiple-of-PACKET_SZ handling works correctly in 
succession
+    for (int i = 1; i <= 5; i++) {
+      assertFetchPacketsSuccess(i * PACKET_SZ, "test-" + i + 
"mb-successive.bin");
+    }
+  }
+
+  @Test
+  public void testChecksumMismatchReturnsError() throws Exception {
+    int fileSize = 1024;
+    String fileName = "test-checksum-mismatch.bin";
+
+    byte[] expectedContent = createDeterministicContent(fileSize);
+    byte[] streamBytes = serializeFileToPacketStream(expectedContent, 
fileName);
+
+    // Corrupt the checksum bytes (bytes 4-11 after packet size)
+    // Stream format: int(size) + long(checksum) + data
+    if (streamBytes.length > 12) {
+      streamBytes[5] ^= 0xFF; // Flip bits in checksum
+    }
+
+    ByteArrayOutputStream output = new ByteArrayOutputStream();
+    int result = invokeFetchPackets(streamBytes, output, fileName, fileSize);
+
+    assertEquals("fetchPackets should return 1 for checksum mismatch", 1, 
result);
+  }
+
+  @Test
+  public void testLargeFileTriggersBufferBehavior() throws Exception {
+    // Test with file larger than initial buffer allocation
+    // Initial buffer is min(fileSize, PACKET_SZ), so for 5MB file, buffer 
starts at 1MB
+    // This ensures the code handles multi-packet transfers correctly
+    assertFetchPacketsSuccess(5 * PACKET_SZ + 12345, "test-large-5mb.bin");
+  }
+
+  private void assertFetchPacketsSuccess(int fileSize, String fileName) throws 
Exception {
+    log.info("Testing file transfer: {} ({} bytes)", fileName, fileSize);
+
+    byte[] expectedContent = createDeterministicContent(fileSize);
+    byte[] streamBytes = serializeFileToPacketStream(expectedContent, 
fileName);
+    ByteArrayOutputStream output = new ByteArrayOutputStream();
+
+    int result = invokeFetchPackets(streamBytes, output, fileName, fileSize);
+
+    int expectedResult = (fileSize == 0) ? NO_CONTENT : 0;
+    assertEquals("fetchPackets return code mismatch", expectedResult, result);
+    assertEquals("Output size should match input size", fileSize, 
output.size());
+    assertArrayEquals(
+        "Output content should match expected for " + fileName,
+        expectedContent,
+        output.toByteArray());
+
+    log.info("Successfully verified transfer of {} ({} bytes)", fileName, 
fileSize);
+  }
+
+  private byte[] createDeterministicContent(int size) {
+    byte[] content = new byte[size];
+    for (int i = 0; i < size; i++) {
+      content[i] = (byte) (i % 256);
+    }
+    return content;
+  }
+
+  /** Writes file content to a directory and serializes it using the 
replication packet protocol. */
+  private byte[] serializeFileToPacketStream(byte[] content, String fileName) 
throws Exception {
+    DirectoryFactory directoryFactory = h.getCore().getDirectoryFactory();
+    Directory dir =
+        directoryFactory.get(
+            h.getCore().getNewIndexDir(),
+            DirectoryFactory.DirContext.DEFAULT,
+            h.getCore().getSolrConfig().indexConfig.lockType);
+
+    try {
+      if (content.length > 0) {
+        try (IndexOutput out = dir.createOutput(fileName, IOContext.DEFAULT)) {
+          out.writeBytes(content, content.length);
+        }
+      }
+
+      CoreReplication replicationAPI =
+          new CoreReplication(
+              h.getCore(),
+              new SolrQueryRequestBase(h.getCore(), new 
ModifiableSolrParams()) {},
+              new SolrQueryResponse());
+
+      MethodHandles.Lookup lookup =
+          MethodHandles.privateLookupIn(ReplicationAPIBase.class, 
MethodHandles.lookup());
+      Class<?> directoryFileStreamClass =
+          Class.forName(ReplicationAPIBase.class.getName() + 
"$DirectoryFileStream");
+      MethodHandle doFetchFileHandle =
+          lookup.findVirtual(
+              ReplicationAPIBase.class,
+              "doFetchFile",
+              MethodType.methodType(
+                  directoryFileStreamClass,
+                  String.class,
+                  String.class,
+                  String.class,
+                  String.class,
+                  boolean.class,
+                  boolean.class,
+                  double.class,
+                  Long.class));
+
+      Object stream;
+      try {
+        stream =
+            doFetchFileHandle.invoke(
+                replicationAPI, fileName, "file", null, null, false, true, 
0.0, null);
+      } catch (Exception e) {
+        throw e;
+      } catch (Throwable t) {
+        throw new RuntimeException(t);
+      }
+
+      ByteArrayOutputStream baos = new ByteArrayOutputStream();
+      Method writeMethod = stream.getClass().getMethod("write", 
java.io.OutputStream.class);
+      writeMethod.invoke(stream, baos);
+      return baos.toByteArray();
+    } finally {
+      if (content.length > 0) {
+        try {
+          dir.deleteFile(fileName);
+        } catch (Exception e) {
+          // ignored
+        }
+      }
+      directoryFactory.release(dir);
+    }
+  }
+
+  /**
+   * Deserializes packet stream bytes using FileFetcher.fetchPackets and 
returns the result code.
+   */
+  private int invokeFetchPackets(
+      byte[] streamBytes, ByteArrayOutputStream output, String fileName, long 
expectedSize)
+      throws Exception {
+
+    Class<?> fileFetcherClass = null;
+    for (Class<?> innerClass : IndexFetcher.class.getDeclaredClasses()) {
+      if (innerClass.getSimpleName().equals("FileFetcher")) {
+        fileFetcherClass = innerClass;
+        break;
+      }
+    }
+    assertNotNull("FileFetcher inner class should exist", fileFetcherClass);
+
+    IndexFetcher indexFetcher = createIndexFetcher();
+
+    try {
+      Map<String, Object> fileDetails = new HashMap<>();
+      fileDetails.put("name", fileName);
+      fileDetails.put("size", expectedSize);
+
+      Object mockFileInterface = createMockFileInterface(output);
+
+      MethodHandles.Lookup lookup =
+          MethodHandles.privateLookupIn(fileFetcherClass, 
MethodHandles.lookup());
+      MethodHandle ctorHandle =
+          lookup.findConstructor(
+              fileFetcherClass,
+              MethodType.methodType(
+                  void.class,
+                  IndexFetcher.class,
+                  getFileInterfaceClass(),
+                  Map.class,
+                  String.class,
+                  String.class,
+                  long.class));
+
+      Object fileFetcher;
+      try {
+        fileFetcher =
+            ctorHandle.invoke(indexFetcher, mockFileInterface, fileDetails, 
fileName, "file", 0L);
+      } catch (Exception e) {
+        throw e;
+      } catch (Throwable t) {
+        throw new RuntimeException(t);
+      }
+
+      MethodHandle fetchPacketsHandle =
+          lookup.findVirtual(
+              fileFetcherClass,
+              "fetchPackets",
+              MethodType.methodType(int.class, FastInputStream.class));
+
+      FastInputStream fis = new FastInputStream(new 
ByteArrayInputStream(streamBytes));
+      try {
+        return (int) fetchPacketsHandle.invoke(fileFetcher, fis);
+      } catch (Exception e) {
+        throw e;
+      } catch (Throwable t) {
+        throw new RuntimeException(t);
+      }
+    } finally {
+      indexFetcher.destroy();
+    }
+  }
+
+  private IndexFetcher createIndexFetcher() throws Exception {
+    NamedList<Object> initArgs = new NamedList<>();
+    initArgs.add("leaderUrl", "http://localhost:8983/solr/collection1";);
+    return new IndexFetcher(initArgs, null, h.getCore());
+  }
+
+  private Class<?> getFileInterfaceClass() throws Exception {
+    for (Class<?> innerClass : IndexFetcher.class.getDeclaredClasses()) {
+      if (innerClass.getSimpleName().equals("FileInterface")) {
+        return innerClass;
+      }
+    }
+    throw new AssertionError("FileInterface not found");
+  }
+
+  /** Creates a mock FileInterface that captures written bytes to the provided 
output stream. */
+  private Object createMockFileInterface(ByteArrayOutputStream output) throws 
Exception {
+    Class<?> fileInterfaceClass = getFileInterfaceClass();
+
+    return java.lang.reflect.Proxy.newProxyInstance(
+        fileInterfaceClass.getClassLoader(),
+        new Class<?>[] {fileInterfaceClass},
+        (proxy, method, args) -> {
+          switch (method.getName()) {
+            case "write":
+              byte[] buf = (byte[]) args[0];
+              int packetSize = (Integer) args[1];
+              output.write(buf, 0, packetSize);
+              return null;
+            case "sync":
+            case "close":
+            case "delete":
+              return null;
+            default:
+              throw new UnsupportedOperationException("Unexpected method: " + 
method.getName());
+          }
+        });
+  }
+}

Reply via email to