Skip to content

Commit 4f130b8

Browse files
committed
SOLR-18194: fix nested docs detection false positive (#4279)
Previously if a segment had updates to the same Solr document (delete + add within a commit interval) the UPDATECOREINDEX action would falsely identify it as having child documents. These are not supported by the action so it would unnecessarily fail. We improve the check to compare cardinality of id with _root_ to identify child documents. (cherry picked from commit 744184a)
1 parent 82e88ef commit 4f130b8

2 files changed

Lines changed: 136 additions & 21 deletions

File tree

solr/core/src/java/org/apache/solr/handler/admin/api/UpgradeCoreIndex.java

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,11 @@ private UpgradeCoreIndexResponse performUpgradeImpl(
149149

150150
RefCounted<SolrIndexSearcher> searcherRef = core.getSearcher();
151151
try {
152-
// Check for nested documents before processing - we don't support them
153-
if (indexContainsNestedDocs(searcherRef.get())) {
152+
// Check for child documents before processing - we don't support them
153+
if (indexContainsChildDocs(searcherRef.get())) {
154154
throw new SolrException(
155155
BAD_REQUEST,
156-
"UPGRADECOREINDEX does not support indexes containing nested documents. "
156+
"UPGRADECOREINDEX does not support indexes containing child/nested documents. "
157157
+ " Consider reindexing your data "
158158
+ "from the original source.");
159159
}
@@ -259,26 +259,44 @@ private boolean shouldUpgradeSegment(LeafReaderContext lrc) {
259259
return (segmentMinVersion == null || segmentMinVersion.major < Version.LATEST.major);
260260
}
261261

262-
private boolean indexContainsNestedDocs(SolrIndexSearcher searcher) throws IOException {
262+
private boolean indexContainsChildDocs(SolrIndexSearcher searcher) throws IOException {
263263
IndexSchema schema = searcher.getSchema();
264264

265-
// First check if schema supports nested docs
265+
// First check if schema supports child docs
266266
if (!schema.isUsableForChildDocs()) {
267267
return false;
268268
}
269269

270-
// Check if _root_ field has fewer unique values than documents with that field.
271-
// This indicates multiple docs share the same _root_ (i.e., child docs exist)
270+
String uniqueKeyFieldName = schema.getUniqueKeyField().getName();
271+
272+
// Compare unique _root_ values against unique id values per segment.
273+
// For non-child docs, every document's _root_ equals its own id, so the number of
274+
// distinct _root_ values equals the number of distinct id values. For child docs,
275+
// children share the parent's _root_ value, so there are fewer distinct _root_ values
276+
// than distinct id values.
277+
//
278+
// We intentionally compare against unique id values rather than Terms.getDocCount()
279+
// (the number of documents with the _root_ field) because segment-level term statistics
280+
// include deleted documents. Updates (delete + re-add of the same id) can leave multiple
281+
// documents with the same _root_ value within a segment, causing getDocCount() to exceed
282+
// the unique _root_ count even when no child docs exist.
272283
IndexReader reader = searcher.getIndexReader();
273284
for (LeafReaderContext leaf : reader.leaves()) {
274-
Terms terms = leaf.reader().terms(IndexSchema.ROOT_FIELD_NAME);
275-
if (terms != null) {
276-
long uniqueRootValues = terms.size();
277-
int docsWithRoot = terms.getDocCount();
278-
279-
if (uniqueRootValues == -1 || uniqueRootValues < docsWithRoot) {
280-
return true; // Codec doesn't store number of terms (so a safe fallback), or multiple docs
281-
// share same _root_ (aka nested docs exist)
285+
Terms rootTerms = leaf.reader().terms(IndexSchema.ROOT_FIELD_NAME);
286+
if (rootTerms != null) {
287+
long uniqueRootValues = rootTerms.size();
288+
if (uniqueRootValues == -1) {
289+
return true; // Codec doesn't report term count; assume child docs as a safe fallback
290+
}
291+
292+
Terms idTerms = leaf.reader().terms(uniqueKeyFieldName);
293+
long uniqueIdValues = (idTerms != null) ? idTerms.size() : -1;
294+
if (uniqueIdValues == -1) {
295+
return true; // Codec doesn't report term count; assume child docs as a safe fallback
296+
}
297+
298+
if (uniqueRootValues < uniqueIdValues) {
299+
return true; // Fewer distinct _root_ values than distinct ids means child docs exist
282300
}
283301
}
284302
}

solr/core/src/test/org/apache/solr/handler/admin/UpgradeCoreIndexActionTest.java

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -335,11 +335,11 @@ private static class SegmentLayout {
335335
}
336336

337337
@Test
338-
public void testUpgradeCoreIndexFailsWithNestedDocuments() throws Exception {
338+
public void testUpgradeCoreIndexFailsWithChildDocuments() throws Exception {
339339
final SolrCore core = h.getCore();
340340
final String coreName = core.getName();
341341

342-
// Create a parent document with a child document (nested doc)
342+
// Create a parent document with a child document
343343
SolrInputDocument parentDoc = new SolrInputDocument();
344344
parentDoc.addField("id", "100");
345345
parentDoc.addField("title", "Parent Document");
@@ -350,7 +350,7 @@ public void testUpgradeCoreIndexFailsWithNestedDocuments() throws Exception {
350350

351351
parentDoc.addChildDocument(childDoc);
352352

353-
// Index the nested document
353+
// Index the parent+child document
354354
LocalSolrQueryRequest req = new LocalSolrQueryRequest(core, new ModifiableSolrParams());
355355
try {
356356
AddUpdateCommand cmd = new AddUpdateCommand(req);
@@ -364,7 +364,7 @@ public void testUpgradeCoreIndexFailsWithNestedDocuments() throws Exception {
364364
// Verify documents were indexed (parent + child = 2 docs)
365365
assertQ(req("q", "*:*"), "//result[@numFound='2']");
366366

367-
// Attempt to upgrade the index - should fail because of nested documents
367+
// Attempt to upgrade the index - should fail because of child documents
368368
CoreAdminHandler admin = new CoreAdminHandler(h.getCoreContainer());
369369
try {
370370
final SolrQueryResponse resp = new SolrQueryResponse();
@@ -380,13 +380,110 @@ public void testUpgradeCoreIndexFailsWithNestedDocuments() throws Exception {
380380
coreName),
381381
resp));
382382

383-
// Verify the exception message indicates nested documents are not supported
383+
// Verify the exception message indicates child documents are not supported
384384
assertThat(
385385
thrown.getMessage(),
386-
containsString("does not support indexes containing nested documents"));
386+
containsString("does not support indexes containing child/nested documents"));
387387
} finally {
388388
admin.shutdown();
389389
admin.close();
390390
}
391391
}
392+
393+
@Test
394+
public void testChildDocsDetection_noChildDocs() throws Exception {
395+
addDocsWithRandomUpdatesAndDeletes();
396+
397+
final String coreName = h.getCore().getName();
398+
CoreAdminHandler admin = new CoreAdminHandler(h.getCoreContainer());
399+
try {
400+
final SolrQueryResponse resp = new SolrQueryResponse();
401+
admin.handleRequestBody(
402+
req(
403+
CoreAdminParams.ACTION,
404+
CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX.toString(),
405+
CoreAdminParams.CORE,
406+
coreName),
407+
resp);
408+
assertNull("Unexpected exception: " + resp.getException(), resp.getException());
409+
} finally {
410+
admin.shutdown();
411+
admin.close();
412+
}
413+
}
414+
415+
@Test
416+
public void testChildDocsDetection_withChildDocs() throws Exception {
417+
addChildDoc("100", "101");
418+
addDocsWithRandomUpdatesAndDeletes();
419+
420+
final String coreName = h.getCore().getName();
421+
CoreAdminHandler admin = new CoreAdminHandler(h.getCoreContainer());
422+
try {
423+
final SolrQueryResponse resp = new SolrQueryResponse();
424+
SolrException thrown =
425+
assertThrows(
426+
SolrException.class,
427+
() ->
428+
admin.handleRequestBody(
429+
req(
430+
CoreAdminParams.ACTION,
431+
CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX.toString(),
432+
CoreAdminParams.CORE,
433+
coreName),
434+
resp));
435+
assertThat(
436+
thrown.getMessage(),
437+
containsString("does not support indexes containing child/nested documents"));
438+
} finally {
439+
admin.shutdown();
440+
admin.close();
441+
}
442+
}
443+
444+
/**
445+
* Add non-child docs with a random number of within-commit updates and deletes. This exercises
446+
* the false-positive scenario for child doc detection: updates and deletes leave behind deleted
447+
* entries in the same segment, causing multiple docs to share the same {@code _root_} value.
448+
*
449+
* <p>With NoMergePolicy and a 100MB RAM buffer (from SolrIndexConfig defaults), no flush or merge
450+
* occurs mid-batch, guaranteeing co-location in a single segment.
451+
*/
452+
private void addDocsWithRandomUpdatesAndDeletes() {
453+
int numDocs = 10;
454+
for (int i = 0; i < numDocs; i++) {
455+
assertU(adoc("id", String.valueOf(i), "title", "doc" + i));
456+
}
457+
int numUpdates = random().nextInt(4);
458+
for (int i = 0; i < numUpdates; i++) {
459+
assertU(adoc("id", String.valueOf(i), "title", "updated_doc" + i));
460+
}
461+
int numDeletes = random().nextInt(4);
462+
for (int i = 0; i < numDeletes; i++) {
463+
assertU(delI(String.valueOf(numDocs - 1 - i)));
464+
}
465+
assertU(commit("openSearcher", "true"));
466+
}
467+
468+
/** Index a parent document with a single child via the update handler. */
469+
private void addChildDoc(String parentId, String childId) throws Exception {
470+
SolrCore core = h.getCore();
471+
SolrInputDocument parentDoc = new SolrInputDocument();
472+
parentDoc.addField("id", parentId);
473+
parentDoc.addField("title", "Parent " + parentId);
474+
475+
SolrInputDocument childDoc = new SolrInputDocument();
476+
childDoc.addField("id", childId);
477+
childDoc.addField("title", "Child " + childId);
478+
parentDoc.addChildDocument(childDoc);
479+
480+
LocalSolrQueryRequest solrReq = new LocalSolrQueryRequest(core, new ModifiableSolrParams());
481+
try {
482+
AddUpdateCommand cmd = new AddUpdateCommand(solrReq);
483+
cmd.solrDoc = parentDoc;
484+
core.getUpdateHandler().addDoc(cmd);
485+
} finally {
486+
solrReq.close();
487+
}
488+
}
392489
}

0 commit comments

Comments
 (0)