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 00000000000..78cb96be26b --- /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 9312b831e75..d9f404eced0 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 87fdad981d6..590ea78aab0 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 c55843dab9d..661de75923c 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 d7f7fa4f2e1..967dd8853e0 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);