From f5f7b5c600afb3fd01c6fb8be9cfe94158ae1f62 Mon Sep 17 00:00:00 2001 From: Olivier Boudet Date: Thu, 6 Aug 2026 21:39:57 +0200 Subject: [PATCH] SOLR-18335 : enable shard splitting by point fields Previously, shard splitting could not utilize numeric point fields as router fields. This change introduces the necessary logic to extract routing values from point fields, either via docValues or stored fields, thus expanding the types of fields available for shard splitting. --- ...35-splitshard-router-field-point-field.yml | 8 + .../apache/solr/update/SolrIndexSplitter.java | 176 +++++++++++++++--- .../solr/collection1/conf/schema15.xml | 2 + .../cloud/api/collections/ShardSplitTest.java | 70 +++++++ .../solr/update/SolrIndexSplitterTest.java | 87 +++++++++ 5 files changed, 318 insertions(+), 25 deletions(-) create mode 100644 changelog/unreleased/SOLR-18335-splitshard-router-field-point-field.yml diff --git a/changelog/unreleased/SOLR-18335-splitshard-router-field-point-field.yml b/changelog/unreleased/SOLR-18335-splitshard-router-field-point-field.yml new file mode 100644 index 000000000000..78cb96be26ba --- /dev/null +++ b/changelog/unreleased/SOLR-18335-splitshard-router-field-point-field.yml @@ -0,0 +1,8 @@ +title: > + SPLITSHARD fails to migrate documents when using a numeric PointField as router.field +type: fixed +authors: + - name: Olivier Boudet +links: + - name: SOLR-18335 + url: https://issues.apache.org/jira/browse/SOLR-18335 diff --git a/solr/core/src/java/org/apache/solr/update/SolrIndexSplitter.java b/solr/core/src/java/org/apache/solr/update/SolrIndexSplitter.java index 9312b831e75c..d9f404eced08 100644 --- a/solr/core/src/java/org/apache/solr/update/SolrIndexSplitter.java +++ b/solr/core/src/java/org/apache/solr/update/SolrIndexSplitter.java @@ -28,14 +28,18 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicInteger; +import org.apache.lucene.document.Document; import org.apache.lucene.index.CodecReader; +import org.apache.lucene.index.DocValues; import org.apache.lucene.index.FilterCodecReader; import org.apache.lucene.index.IndexReader; import org.apache.lucene.index.IndexWriter; import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.IndexableField; import org.apache.lucene.index.LeafReader; import org.apache.lucene.index.LeafReaderContext; import org.apache.lucene.index.NoMergePolicy; +import org.apache.lucene.index.NumericDocValues; import org.apache.lucene.index.PostingsEnum; import org.apache.lucene.index.SlowCodecReaderWrapper; import org.apache.lucene.index.Terms; @@ -70,6 +74,7 @@ import org.apache.solr.handler.IndexFetcher; import org.apache.solr.handler.SnapShooter; import org.apache.solr.schema.IndexSchema; +import org.apache.solr.schema.NumberType; import org.apache.solr.schema.SchemaField; import org.apache.solr.search.BitsFilteredPostingsEnum; import org.apache.solr.search.SolrIndexSearcher; @@ -677,6 +682,20 @@ static FixedBitSet[] split( } } + if (field.getType().isPointField()) { + return splitPointField( + reader, + numPieces, + field, + rangesArr, + splitKey, + hashRouter, + delete, + docSets, + liveDocs, + currentPartition); + } + Terms terms = reader.terms(field.getName()); TermsEnum termsEnum = terms == null ? null : terms.iterator(); if (termsEnum == null) return docSets; @@ -746,38 +765,145 @@ static FixedBitSet[] split( } if (docsMatchingRanges != null) { - for (int ii = 0; ii < docsMatchingRanges.length; ii++) { - if (0 == docsMatchingRanges[ii]) continue; - switch (ii) { - case 0: - // document loss - log.error( - "Splitting {}: {} documents belong to no shards and will be dropped", - reader, - docsMatchingRanges[ii]); - break; - case 1: - // normal case, each document moves to one of the sub-shards - log.info( - "Splitting {}: {} documents will move into a sub-shard", - reader, - docsMatchingRanges[ii]); - break; - default: - // document duplication - log.error( - "Splitting {}: {} documents will be moved to multiple ({}) sub-shards", - reader, - docsMatchingRanges[ii], - ii); - break; + logDocsMatchingRanges(reader, docsMatchingRanges); + } + + return docSets; + } + + private static FixedBitSet[] splitPointField( + LeafReader reader, + int numPieces, + SchemaField field, + DocRouter.Range[] rangesArr, + String splitKey, + HashBasedRouter hashRouter, + boolean delete, + FixedBitSet[] docSets, + Bits liveDocs, + AtomicInteger currentPartition) + throws IOException { + NumericDocValues numericDocValues = + field.hasDocValues() ? DocValues.getNumeric(reader, field.getName()) : null; + + int[] docsMatchingRanges = null; + if (rangesArr != null) { + docsMatchingRanges = new int[rangesArr.length + 1]; + } + + for (int doc = 0; doc < reader.maxDoc(); doc++) { + if (liveDocs != null && !liveDocs.get(doc)) { + continue; + } + + String routeValue = getRouteFieldValue(reader, doc, field, numericDocValues); + if (splitKey != null) { + String part1 = ((CompositeIdRouter) hashRouter).getRouteKeyNoSuffix(routeValue); + if (part1 == null || !splitKey.equals(part1)) { + continue; } } + + if (rangesArr == null) { + if (delete) { + docSets[currentPartition.get()].clear(doc); + } else { + docSets[currentPartition.get()].set(doc); + } + currentPartition.set((currentPartition.get() + 1) % numPieces); + } else { + int hash = hashRouter.sliceHash(routeValue, null, null, null); + int matchingRangesCount = 0; + for (int i = 0; i < rangesArr.length; i++) { + if (rangesArr[i].includes(hash)) { + if (delete) { + docSets[i].clear(doc); + } else { + docSets[i].set(doc); + } + ++matchingRangesCount; + } + } + docsMatchingRanges[matchingRangesCount]++; + } } + if (docsMatchingRanges != null) { + logDocsMatchingRanges(reader, docsMatchingRanges); + } return docSets; } + private static String getRouteFieldValue( + LeafReader reader, int doc, SchemaField field, NumericDocValues numericDocValues) + throws IOException { + if (numericDocValues != null && numericDocValues.advanceExact(doc)) { + return numericRouteValueToString(field, numericDocValues.longValue()); + } + + if (field.stored()) { + Document storedDocument = reader.storedFields().document(doc); + IndexableField storedField = storedDocument.getField(field.getName()); + if (storedField != null) { + Object routeValue = field.getType().toObject(storedField); + if (routeValue != null) { + return routeValue.toString(); + } + } + } + + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "Unable to read route field '" + + field.getName() + + "' for shard splitting. Point-based route fields must expose docValues or be stored."); + } + + private static String numericRouteValueToString(SchemaField field, long value) { + NumberType numberType = field.getType().getNumberType(); + if (numberType == null) { + return Long.toString(value); + } + + return switch (numberType) { + case INTEGER -> Integer.toString((int) value); + case LONG -> Long.toString(value); + case FLOAT -> Float.toString(Float.intBitsToFloat((int) value)); + case DOUBLE -> Double.toString(Double.longBitsToDouble(value)); + case DATE -> Long.toString(value); + }; + } + + private static void logDocsMatchingRanges(LeafReader reader, int[] docsMatchingRanges) { + for (int ii = 0; ii < docsMatchingRanges.length; ii++) { + if (0 == docsMatchingRanges[ii]) continue; + switch (ii) { + case 0: + // document loss + log.error( + "Splitting {}: {} documents belong to no shards and will be dropped", + reader, + docsMatchingRanges[ii]); + break; + case 1: + // normal case, each document moves to one of the sub-shards + log.info( + "Splitting {}: {} documents will move into a sub-shard", + reader, + docsMatchingRanges[ii]); + break; + default: + // document duplication + log.error( + "Splitting {}: {} documents will be moved to multiple ({}) sub-shards", + reader, + docsMatchingRanges[ii], + ii); + break; + } + } + } + private static void checkRouterSupportsSplitKey(HashBasedRouter hashRouter, String splitKey) { if (splitKey != null && !(hashRouter instanceof CompositeIdRouter)) { throw new IllegalStateException( diff --git a/solr/core/src/test-files/solr/collection1/conf/schema15.xml b/solr/core/src/test-files/solr/collection1/conf/schema15.xml index 87fdad981d67..590ea78aab0e 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema15.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema15.xml @@ -35,6 +35,7 @@ + @@ -595,6 +596,7 @@ + diff --git a/solr/core/src/test/org/apache/solr/cloud/api/collections/ShardSplitTest.java b/solr/core/src/test/org/apache/solr/cloud/api/collections/ShardSplitTest.java index c55843dab9d9..661de75923cf 100644 --- a/solr/core/src/test/org/apache/solr/cloud/api/collections/ShardSplitTest.java +++ b/solr/core/src/test/org/apache/solr/cloud/api/collections/ShardSplitTest.java @@ -107,6 +107,7 @@ public void test() throws Exception { incompleteOrOverlappingCustomRangeTest(); splitByUniqueKeyTest(); splitByRouteFieldTest(); + splitByNumericRouteFieldTest(); splitByRouteKeyTest(); // todo can't call waitForThingsToLevelOut because it looks for jettys of all shards @@ -1009,6 +1010,75 @@ public void splitByRouteFieldTest() throws Exception { .query(new SolrQuery("*:*").setParam("shards", "shard1_1")) .getResults() .getNumFound()); + assertEquals(101, collectionClient.query(new SolrQuery("*:*")).getResults().getNumFound()); + } + } + + public void splitByNumericRouteFieldTest() throws Exception { + log.info("Starting splitByNumericRouteFieldTest"); + String collectionName = "numericRouteFieldColl"; + int numShards = 4; + int replicationFactor = 2; + + HashMap> collectionInfos = new HashMap<>(); + String shardField = "shard_pl"; + try (CloudSolrClient client = createCloudClient(null)) { + Map props = + Map.of( + REPLICATION_FACTOR, + replicationFactor, + CollectionHandlingUtils.NUM_SLICES, + numShards, + "router.field", + shardField); + + createCollection(collectionInfos, collectionName, props, client); + } + + List list = collectionInfos.get(collectionName); + checkForCollection(collectionName, list); + + waitForRecoveriesToFinish(false); + + getCommonCloudSolrClient(); + String baseUrl = getBaseUrlFromZk(cloudClient.getClusterState(), collectionName); + + try (SolrClient collectionClient = getHttpSolrClient(baseUrl, collectionName)) { + ClusterState clusterState = cloudClient.getClusterState(); + final DocRouter router = clusterState.getCollection(collectionName).getRouter(); + Slice shard1 = clusterState.getCollection(collectionName).getSlice(SHARD1); + DocRouter.Range shard1Range = + shard1.getRange() != null ? shard1.getRange() : router.fullRange(); + final List ranges = router.partitionRange(2, shard1Range); + final int[] docCounts = new int[ranges.size()]; + + for (int i = 100; i <= 200; i++) { + collectionClient.add(getDoc(id, i, "n_ti", i, shardField, i)); + int idx = getHashRangeIdx(router, ranges, Integer.toString(i)); + if (idx != -1) { + docCounts[idx]++; + } + } + + collectionClient.commit(); + + trySplit(collectionName, null, SHARD1, 3); + + waitForRecoveriesToFinish(collectionName, false); + + assertEquals( + docCounts[0], + collectionClient + .query(new SolrQuery("*:*").setParam("shards", "shard1_0")) + .getResults() + .getNumFound()); + assertEquals( + docCounts[1], + collectionClient + .query(new SolrQuery("*:*").setParam("shards", "shard1_1")) + .getResults() + .getNumFound()); + assertEquals(101, collectionClient.query(new SolrQuery("*:*")).getResults().getNumFound()); } } diff --git a/solr/core/src/test/org/apache/solr/update/SolrIndexSplitterTest.java b/solr/core/src/test/org/apache/solr/update/SolrIndexSplitterTest.java index d7f7fa4f2e11..967dd8853e03 100644 --- a/solr/core/src/test/org/apache/solr/update/SolrIndexSplitterTest.java +++ b/solr/core/src/test/org/apache/solr/update/SolrIndexSplitterTest.java @@ -413,6 +413,16 @@ public void testSplitByRouteKeyLink() throws Exception { doTestSplitByRouteKey(SolrIndexSplitter.SplitMethod.LINK); } + @Test + public void testSplitByNumericRouteField() throws Exception { + doTestSplitByNumericRouteField(SolrIndexSplitter.SplitMethod.REWRITE); + } + + @Test + public void testSplitByNumericRouteFieldLink() throws Exception { + doTestSplitByNumericRouteField(SolrIndexSplitter.SplitMethod.LINK); + } + private void doTestSplitByRouteKey(SolrIndexSplitter.SplitMethod splitMethod) throws Exception { Path indexDir = createTempDir(); @@ -480,6 +490,83 @@ private void doTestSplitByRouteKey(SolrIndexSplitter.SplitMethod splitMethod) th } } + private void doTestSplitByNumericRouteField(SolrIndexSplitter.SplitMethod splitMethod) + throws Exception { + CompositeIdRouter router = new CompositeIdRouter(); + List ranges = router.partitionRange(2, router.fullRange()); + int[] expectedDocCounts = new int[ranges.size()]; + + for (int i = 100; i < 140; i++) { + String routeValue = Integer.toString(i); + assertU(adoc("id", "doc-" + i, "route_pl", routeValue)); + + int hash = router.sliceHash(routeValue, null, null, null); + for (int rangeIndex = 0; rangeIndex < ranges.size(); rangeIndex++) { + if (ranges.get(rangeIndex).includes(hash)) { + expectedDocCounts[rangeIndex]++; + break; + } + } + } + + assertU(commit()); + assertJQ(req("q", "*:*"), "/response/numFound==40"); + + SolrQueryRequestBase request = null; + Directory directory = null; + try { + request = lrf.makeRequest("q", "dummy"); + SolrQueryResponse rsp = new SolrQueryResponse(); + SplitIndexCommand command = + new SplitIndexCommand( + request, + rsp, + List.of(indexDir1.toString(), indexDir2.toString()), + null, + ranges, + router, + "route_pl", + null, + splitMethod); + doSplit(command); + + directory = + h.getCore() + .getDirectoryFactory() + .get( + indexDir1.toString(), + DirectoryFactory.DirContext.DEFAULT, + h.getCore().getSolrConfig().indexConfig.lockType); + DirectoryReader reader = DirectoryReader.open(directory); + assertEquals( + "split index1 has wrong number of documents", expectedDocCounts[0], reader.numDocs()); + reader.close(); + h.getCore().getDirectoryFactory().release(directory); + directory = null; + + directory = + h.getCore() + .getDirectoryFactory() + .get( + indexDir2.toString(), + DirectoryFactory.DirContext.DEFAULT, + h.getCore().getSolrConfig().indexConfig.lockType); + reader = DirectoryReader.open(directory); + assertEquals( + "split index2 has wrong number of documents", expectedDocCounts[1], reader.numDocs()); + reader.close(); + h.getCore().getDirectoryFactory().release(directory); + directory = null; + } finally { + if (request != null) { + request.close(); + } + if (directory != null) { + h.getCore().getDirectoryFactory().release(directory); + } + } + } + @Test public void testSplitWithChildDocs() throws Exception { doTestSplitWithChildDocs(SolrIndexSplitter.SplitMethod.REWRITE);