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

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


The following commit(s) were added to refs/heads/main by this push:
     new 8088be23069 SOLR-18304: Optimize collapse performance for String 
fields (#4621)
8088be23069 is described below

commit 8088be2306979422836006dd992e365fc7b440a6
Author: Bartosz Fidrysiak <[email protected]>
AuthorDate: Mon Jul 13 19:22:42 2026 +0200

    SOLR-18304: Optimize collapse performance for String fields (#4621)
    
    Affects Solr 9.x and beyond.  As of Lucene 9 (technically late 8.x?), 
looking up a value became somewhat expensive, motivating being clever to avoid 
resolving the value.
---
 ...R-18304-optimize-collapse-for-string-fields.yml |   7 +
 .../apache/solr/bench/search/CollapsingSearch.java | 214 +++++++++++++++++++++
 .../configs/cloud-minimal/conf/schema.xml          |   1 +
 .../solr/search/CollapsingQParserPlugin.java       |  73 ++++++-
 .../solr/search/TestCollapseQParserPlugin.java     | 146 ++++++++++++++
 5 files changed, 438 insertions(+), 3 deletions(-)

diff --git 
a/changelog/unreleased/SOLR-18304-optimize-collapse-for-string-fields.yml 
b/changelog/unreleased/SOLR-18304-optimize-collapse-for-string-fields.yml
new file mode 100644
index 00000000000..61ac4b856b6
--- /dev/null
+++ b/changelog/unreleased/SOLR-18304-optimize-collapse-for-string-fields.yml
@@ -0,0 +1,7 @@
+title: Optimize collapse performance for String fields in Solr 9.x and later
+type: changed
+authors:
+  - name: Bartosz Fidrysiak
+links:
+  - name: SOLR-18304
+    url: https://issues.apache.org/jira/browse/SOLR-18304
diff --git 
a/solr/benchmark/src/java/org/apache/solr/bench/search/CollapsingSearch.java 
b/solr/benchmark/src/java/org/apache/solr/bench/search/CollapsingSearch.java
new file mode 100644
index 00000000000..403853b2b3d
--- /dev/null
+++ b/solr/benchmark/src/java/org/apache/solr/bench/search/CollapsingSearch.java
@@ -0,0 +1,214 @@
+/*
+ * 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.bench.search;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import java.util.Random;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import org.apache.solr.bench.BaseBenchState;
+import org.apache.solr.bench.SolrBenchState;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.QueryRequest;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+@Fork(value = 1)
+@Warmup(time = 5, iterations = 5)
+@Measurement(time = 15, iterations = 5)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@Threads(value = 1)
+public class CollapsingSearch {
+
+  static final String COLLECTION = "collapse_bench";
+
+  @State(Scope.Benchmark)
+  public static class BenchState {
+
+    @Param({"2000000"})
+    int numDocs;
+
+    @Param({"100000"})
+    int numGroups;
+
+    @Param({"1", "10"})
+    int numSegments;
+
+    final QueryRequest qSimple =
+        new QueryRequest(new SolrQuery("q", "*:*", "rows", "10", "cache", 
"false"));
+
+    final QueryRequest qCollapseWithoutSort =
+        new QueryRequest(
+            new SolrQuery(
+                "q", "*:*",
+                "fq", "{!collapse field=group_s_dv cache=false}",
+                "rows", "10",
+                "cache", "false"));
+
+    final QueryRequest qCollapseByStr =
+        new QueryRequest(
+            new SolrQuery(
+                "q", "*:*",
+                "fq", "{!collapse field=group_s_dv sort='str_s_dv asc' 
cache=false}",
+                "rows", "10",
+                "cache", "false"));
+
+    final QueryRequest qCollapseByDate =
+        new QueryRequest(
+            new SolrQuery(
+                "q", "*:*",
+                "fq", "{!collapse field=group_s_dv sort='date_dt_dv asc' 
cache=false}",
+                "rows", "10",
+                "cache", "false"));
+
+    final QueryRequest qCollapseByLong =
+        new QueryRequest(
+            new SolrQuery(
+                "q", "*:*",
+                "fq", "{!collapse field=group_s_dv sort='long_l_dv asc' 
cache=false}",
+                "rows", "10",
+                "cache", "false"));
+
+    final QueryRequest qCollapseByDateAndStr =
+        new QueryRequest(
+            new SolrQuery(
+                "q", "*:*",
+                "fq",
+                    "{!collapse field=group_s_dv sort='date_dt_dv asc, 
str_s_dv asc' cache=false}",
+                "rows", "10",
+                "cache", "false"));
+
+    @Setup(Level.Trial)
+    public void setupTrial(SolrBenchState miniClusterState) throws Exception {
+      System.setProperty("commitwithin.softcommit", "false");
+      miniClusterState.startSolr(1);
+      miniClusterState.createCollection(COLLECTION, 1, 1);
+
+      indexDocs(miniClusterState);
+      miniClusterState.forceMerge(COLLECTION, numSegments);
+
+      BaseBenchState.log(
+          "CollapsingSearch ready: numDocs="
+              + numDocs
+              + " numGroups="
+              + numGroups
+              + " numSegments(requested)="
+              + numSegments);
+    }
+
+    @Setup(Level.Iteration)
+    public void setupIteration(SolrBenchState miniClusterState)
+        throws SolrServerException, IOException {
+      CollectionAdminRequest.Reload reload = 
CollectionAdminRequest.reloadCollection(COLLECTION);
+      miniClusterState.client.request(reload);
+    }
+
+    @TearDown(Level.Trial)
+    public void teardown(SolrBenchState miniClusterState) throws Exception {
+      
CollectionAdminRequest.deleteCollection(COLLECTION).process(miniClusterState.client);
+    }
+
+    private int pickGroup(int docId) {
+      return docId % numGroups;
+    }
+
+    private void indexDocs(SolrBenchState state) throws Exception {
+      int docsPerSegment = numDocs / numSegments;
+      String[] groupIds = new String[numGroups];
+      for (int i = 0; i < numGroups; i++) {
+        groupIds[i] = String.format(Locale.ROOT, "group_%06d", i);
+      }
+      Random rng = new Random(42);
+      Instant baseDate = Instant.parse("2020-01-01T00:00:00Z");
+
+      int docId = 0;
+      for (int seg = 0; seg < numSegments; seg++) {
+        int segDocs = (seg < numSegments - 1) ? docsPerSegment : (numDocs - 
docId);
+        UpdateRequest req = new UpdateRequest();
+        for (int i = 0; i < segDocs; i++) {
+          SolrInputDocument doc = new SolrInputDocument();
+          doc.addField("id", String.valueOf(docId));
+          doc.addField("group_s_dv", groupIds[pickGroup(docId)]);
+          docId++;
+          doc.addField("str_s_dv", UUID.randomUUID().toString());
+          doc.addField(
+              "date_dt_dv",
+              
DateTimeFormatter.ISO_INSTANT.format(baseDate.plusSeconds(rng.nextInt(10_000_000))));
+          doc.addField("long_l_dv", rng.nextInt(10_000_000));
+          req.add(doc);
+        }
+        req.commit(state.client, COLLECTION);
+      }
+    }
+  }
+
+  @Benchmark
+  public Object simple(BenchState b, SolrBenchState cluster)
+      throws SolrServerException, IOException {
+    return cluster.client.request(b.qSimple, COLLECTION);
+  }
+
+  @Benchmark
+  public Object collapseWithoutSort(BenchState b, SolrBenchState cluster)
+      throws SolrServerException, IOException {
+    return cluster.client.request(b.qCollapseWithoutSort, COLLECTION);
+  }
+
+  @Benchmark
+  public Object collapseByStr(BenchState b, SolrBenchState cluster)
+      throws SolrServerException, IOException {
+    return cluster.client.request(b.qCollapseByStr, COLLECTION);
+  }
+
+  @Benchmark
+  public Object collapseByDate(BenchState b, SolrBenchState cluster)
+      throws SolrServerException, IOException {
+    return cluster.client.request(b.qCollapseByDate, COLLECTION);
+  }
+
+  @Benchmark
+  public Object collapseByLong(BenchState b, SolrBenchState cluster)
+      throws SolrServerException, IOException {
+    return cluster.client.request(b.qCollapseByLong, COLLECTION);
+  }
+
+  @Benchmark
+  public Object collapseByDateAndStr(BenchState b, SolrBenchState cluster)
+      throws SolrServerException, IOException {
+    return cluster.client.request(b.qCollapseByDateAndStr, COLLECTION);
+  }
+}
diff --git a/solr/benchmark/src/resources/configs/cloud-minimal/conf/schema.xml 
b/solr/benchmark/src/resources/configs/cloud-minimal/conf/schema.xml
index b1851c3c290..e3f77a82604 100644
--- a/solr/benchmark/src/resources/configs/cloud-minimal/conf/schema.xml
+++ b/solr/benchmark/src/resources/configs/cloud-minimal/conf/schema.xml
@@ -56,6 +56,7 @@
     <dynamicField name="*_d_dv" type="double" indexed="true" docValues="true" 
stored="false"/>
     <dynamicField name="*_dt" type="date" indexed="true" stored="false"/>
     <dynamicField name="*_dt_dv" type="date" indexed="true" docValues="true" 
stored="false"/>
+    <dynamicField name="*_s_dv" type="string" indexed="true" docValues="true" 
stored="false"/>
 
     <uniqueKey>id</uniqueKey>
 </schema>
diff --git 
a/solr/core/src/java/org/apache/solr/search/CollapsingQParserPlugin.java 
b/solr/core/src/java/org/apache/solr/search/CollapsingQParserPlugin.java
index ae3102e41ce..5c76a1eb98c 100644
--- a/solr/core/src/java/org/apache/solr/search/CollapsingQParserPlugin.java
+++ b/solr/core/src/java/org/apache/solr/search/CollapsingQParserPlugin.java
@@ -3565,6 +3565,9 @@ public class CollapsingQParserPlugin extends 
QParserPlugin {
     private Object[][] groupHeadValues; // growable
     private final Object[] nullGroupValues;
 
+    private final SortedDocValues[] stringSortDVs;
+    private final int[] stringMissingOrd;
+
     /**
      * Constructs an instance based on the (raw, un-rewritten) SortFields to 
be used, and an initial
      * number of expected groups (will grow as needed).
@@ -3576,6 +3579,7 @@ public class CollapsingQParserPlugin extends 
QParserPlugin {
       fieldComparators = new FieldComparator[numClauses];
       leafFieldComparators = new LeafFieldComparator[numClauses];
       reverseMul = new int[numClauses];
+      stringMissingOrd = new int[numClauses];
       for (int clause = 0; clause < numClauses; clause++) {
         SortField sf = sorts[clause];
         // we only need one slot for every comparator
@@ -3587,14 +3591,28 @@ public class CollapsingQParserPlugin extends 
QParserPlugin {
                     : Pruning.NONE);
 
         reverseMul[clause] = sf.getReverse() ? -1 : 1;
+        if (sf.getType() == SortField.Type.STRING) {
+          stringMissingOrd[clause] =
+              (sf.getMissingValue() == SortField.STRING_LAST) ? 
Integer.MAX_VALUE : -1;
+        }
       }
       groupHeadValues = new Object[initNumGroups][];
       nullGroupValues = new Object[numClauses];
+      stringSortDVs = new SortedDocValues[numClauses]; // populated in 
setNextReader
     }
 
     public void setNextReader(LeafReaderContext context) throws IOException {
       for (int clause = 0; clause < numClauses; clause++) {
         leafFieldComparators[clause] = 
fieldComparators[clause].getLeafComparator(context);
+        if (sorts[clause].getType() == SortField.Type.STRING) {
+          String field = sorts[clause].getField();
+          FieldInfo fi = context.reader().getFieldInfos().fieldInfo(field);
+          if (fi != null && fi.getDocValuesType() == DocValuesType.SORTED) {
+            stringSortDVs[clause] = DocValues.getSorted(context.reader(), 
field);
+          } else {
+            stringSortDVs[clause] = null;
+          }
+        }
       }
     }
 
@@ -3652,12 +3670,20 @@ public class CollapsingQParserPlugin extends 
QParserPlugin {
 
     /**
      * Records the SortField values for the specified contextDoc into the 
values array provided by
-     * the caller.
+     * the caller. STRING clauses with SORTED DocValues are stored as {@link 
LazyStringValue} to
+     * defer {@code lookupOrd()} until a second document actually competes for 
the group.
      */
     private void setGroupValues(Object[] values, int contextDoc) throws 
IOException {
       for (int clause = 0; clause < numClauses; clause++) {
-        leafFieldComparators[clause].copy(0, contextDoc);
-        values[clause] = cloneIfBytesRef(fieldComparators[clause].value(0));
+        if (stringSortDVs[clause] != null) {
+          SortedDocValues dv = stringSortDVs[clause];
+          int missingOrd = stringMissingOrd[clause];
+          int ord = dv.advanceExact(contextDoc) ? dv.ordValue() : missingOrd;
+          values[clause] = new LazyStringValue(dv, ord, missingOrd);
+        } else {
+          leafFieldComparators[clause].copy(0, contextDoc);
+          values[clause] = cloneIfBytesRef(fieldComparators[clause].value(0));
+        }
       }
     }
 
@@ -3696,6 +3722,20 @@ public class CollapsingQParserPlugin extends 
QParserPlugin {
       int testClause = 0;
       for (
       /* testClause */ ; testClause < numClauses; testClause++) {
+        if (values[testClause] instanceof LazyStringValue headVal) {
+          SortedDocValues segDV = stringSortDVs[testClause];
+          if (segDV != null && segDV == headVal.dv) {
+            int missingOrd = headVal.missingOrd;
+            int candidateOrd = segDV.advanceExact(contextDoc) ? 
segDV.ordValue() : missingOrd;
+            lastCompare = reverseMul[testClause] * 
Integer.compare(candidateOrd, headVal.ord);
+            stash[testClause] = new LazyStringValue(segDV, candidateOrd, 
missingOrd);
+            if (0 != lastCompare) break;
+            continue;
+          }
+          var materializedHeadVal = headVal.materialize();
+          values[testClause] =
+              materializedHeadVal != null ? 
BytesRef.deepCopyOf(materializedHeadVal) : null;
+        }
         leafFieldComparators[testClause].copy(0, contextDoc);
         FieldComparator fcomp = fieldComparators[testClause];
         stash[testClause] = cloneIfBytesRef(fcomp.value(0));
@@ -3719,6 +3759,13 @@ public class CollapsingQParserPlugin extends 
QParserPlugin {
       System.arraycopy(stash, 0, values, 0, testClause);
       // read the remaining values we didn't need to test
       for (int copyClause = testClause; copyClause < numClauses; copyClause++) 
{
+        SortedDocValues segDV = stringSortDVs[copyClause];
+        if (segDV != null) {
+          int missingOrd = stringMissingOrd[copyClause];
+          int candidateOrd = segDV.advanceExact(contextDoc) ? segDV.ordValue() 
: missingOrd;
+          values[copyClause] = new LazyStringValue(segDV, candidateOrd, 
missingOrd);
+          continue;
+        }
         leafFieldComparators[copyClause].copy(0, contextDoc);
         values[copyClause] = 
cloneIfBytesRef(fieldComparators[copyClause].value(0));
       }
@@ -3785,6 +3832,26 @@ public class CollapsingQParserPlugin extends 
QParserPlugin {
     }
   }
 
+  private static final class LazyStringValue {
+
+    final SortedDocValues dv;
+    final int ord;
+    final int missingOrd;
+
+    LazyStringValue(SortedDocValues dv, int ord, int missingOrd) {
+      this.dv = dv;
+      this.ord = ord;
+      this.missingOrd = missingOrd;
+    }
+
+    BytesRef materialize() throws IOException {
+      if (ord == missingOrd || ord < 0) {
+        return null;
+      }
+      return dv.lookupOrd(ord);
+    }
+  }
+
   /** returns the number of arguments that are non null */
   private static final int numNotNull(final Object... args) {
     int r = 0;
diff --git 
a/solr/core/src/test/org/apache/solr/search/TestCollapseQParserPlugin.java 
b/solr/core/src/test/org/apache/solr/search/TestCollapseQParserPlugin.java
index 716a4a129bc..7c193f87a73 100644
--- a/solr/core/src/test/org/apache/solr/search/TestCollapseQParserPlugin.java
+++ b/solr/core/src/test/org/apache/solr/search/TestCollapseQParserPlugin.java
@@ -1578,4 +1578,150 @@ public class TestCollapseQParserPlugin extends 
SolrTestCaseJ4 {
       }
     }
   }
+
+  @Test
+  public void testCollapseStringSortLazyLoadingTieDoesNotEvictGroupHead() {
+    // Group A: id=1 and id=2 both have term_s_dv=AAA → tie on the sort field.
+    // Group B: id=3 (BBB) and id=4 (CCC) → no tie, id=3 is the winner on asc.
+    assertU(adoc("id", "1", "group_s_dv", "A", "term_s_dv", "AAA"));
+    assertU(adoc("id", "2", "group_s_dv", "A", "term_s_dv", "AAA"));
+    assertU(adoc("id", "3", "group_s_dv", "B", "term_s_dv", "BBB"));
+    assertU(adoc("id", "4", "group_s_dv", "B", "term_s_dv", "CCC"));
+    assertU(commit());
+
+    // Single segment: ordinal fast path, tie → first doc (id=1) stays as 
group head.
+    assertQ(
+        req(
+            "q", "*:*",
+            "fq", "{!collapse field=group_s_dv sort='term_s_dv asc'}",
+            "sort", "id_i asc",
+            "fl", "id"),
+        "*[count(//doc)=2]",
+        "//result/doc[1]/str[@name='id'][.='1']",
+        "//result/doc[2]/str[@name='id'][.='3']");
+
+    // Multi-segment: slow path, same tie expectation.
+    assertU(adoc("id", "5", "group_s_dv", "A", "term_s_dv", "AAA"));
+    assertU(commit());
+
+    assertQ(
+        req(
+            "q", "*:*",
+            "fq", "{!collapse field=group_s_dv sort='term_s_dv asc'}",
+            "sort", "id_i asc",
+            "fl", "id"),
+        "*[count(//doc)=2]",
+        "//result/doc[1]/str[@name='id'][.='1']",
+        "//result/doc[2]/str[@name='id'][.='3']");
+  }
+
+  @Test
+  public void testCollapseStringSortOrdinalFastPathMultiClauseTieBreaking() {
+    // Group A: id=1 (AAA, ZZZ) vs id=2 (AAA, MMM) → clause-1 ties (both AAA),
+    //          clause-2 decides: MMM < ZZZ → id=2 wins on 'term_s_dv asc, 
term2_s_dv asc'.
+    // Group B: id=3 (BBB, X) vs id=4 (CCC, X) → clause-1 decides, no tie.
+    assertU(adoc("id", "1", "group_s_dv", "A", "term_s_dv", "AAA", 
"term2_s_dv", "ZZZ"));
+    assertU(adoc("id", "2", "group_s_dv", "A", "term_s_dv", "AAA", 
"term2_s_dv", "MMM"));
+    assertU(adoc("id", "3", "group_s_dv", "B", "term_s_dv", "BBB", 
"term2_s_dv", "X"));
+    assertU(adoc("id", "4", "group_s_dv", "B", "term_s_dv", "CCC", 
"term2_s_dv", "X"));
+    assertU(commit());
+
+    // Single segment: ordinal fast path on clause-1 (tie), then clause-2 
decides.
+    assertQ(
+        req(
+            "q", "*:*",
+            "fq", "{!collapse field=group_s_dv sort='term_s_dv asc,term2_s_dv 
asc'}",
+            "sort", "id_i asc",
+            "fl", "id"),
+        "*[count(//doc)=2]",
+        "//result/doc[1]/str[@name='id'][.='2']",
+        "//result/doc[2]/str[@name='id'][.='3']");
+
+    // Add a cross-segment competitor for group A to exercise the slow path 
too.
+    assertU(adoc("id", "5", "group_s_dv", "A", "term_s_dv", "AAA", 
"term2_s_dv", "AAA"));
+    assertU(commit());
+
+    // id=5 (AAA, AAA) beats id=2 (AAA, MMM) because AAA < MMM on clause-2.
+    assertQ(
+        req(
+            "q", "*:*",
+            "fq", "{!collapse field=group_s_dv sort='term_s_dv asc,term2_s_dv 
asc'}",
+            "sort", "id_i asc",
+            "fl", "id"),
+        "*[count(//doc)=2]",
+        "//result/doc[1]/str[@name='id'][.='3']",
+        "//result/doc[2]/str[@name='id'][.='5']");
+  }
+
+  @Test
+  public void 
testCollapseStringSortWithoutDocValuesSkipsLazyLoadingAndOrdinalFastPath() {
+    // term_s is a *_s field: indexed but no docValues → stringSortDVs[0] will 
be null.
+    // Group A: id=1 (ZZZ) vs id=2 (AAA) → id=2 wins on asc.
+    // Group B: id=3 (MMM) vs id=4 (BBB) → id=4 wins on asc.
+    assertU(adoc("id", "1", "group_s_dv", "A", "term_s", "ZZZ"));
+    assertU(adoc("id", "2", "group_s_dv", "A", "term_s", "AAA"));
+    assertU(adoc("id", "3", "group_s_dv", "B", "term_s", "MMM"));
+    assertU(adoc("id", "4", "group_s_dv", "B", "term_s", "BBB"));
+    assertU(commit());
+    assertU(adoc("id", "5", "group_s_dv", "A", "term_s", "BBB"));
+    assertU(commit());
+
+    assertQ(
+        req(
+            "q", "*:*",
+            "fq", "{!collapse field=group_s_dv sort='term_s asc'}",
+            "sort", "id_i asc",
+            "fl", "id"),
+        "*[count(//doc)=2]",
+        "//result/doc[1]/str[@name='id'][.='2']",
+        "//result/doc[2]/str[@name='id'][.='4']");
+
+    assertQ(
+        req(
+            "q", "*:*",
+            "fq", "{!collapse field=group_s_dv sort='term_s desc'}",
+            "sort", "id_i asc",
+            "fl", "id"),
+        "*[count(//doc)=2]",
+        "//result/doc[1]/str[@name='id'][.='1']",
+        "//result/doc[2]/str[@name='id'][.='3']");
+  }
+
+  @Test
+  public void 
testCollapseStringSortOrdinalFastPathDescendingWithMissingValues() {
+    // Group A: id=1 (ZZZ), id=2 (AAA), id=3 (no term_s_dv → missing, sorts 
last).
+    // On 'term_s_dv desc': ZZZ > AAA > missing → winner is id=1.
+    // Group B: id=4 (MMM), id=5 (no term_s_dv → missing).
+    // On 'term_s_dv desc': MMM > missing → winner is id=4.
+    assertU(adoc("id", "1", "group_s_dv", "A", "term_s_dv", "ZZZ"));
+    assertU(adoc("id", "4", "group_s_dv", "B", "term_s_dv", "MMM"));
+    assertU(commit());
+    assertU(adoc("id", "2", "group_s_dv", "A", "term_s_dv", "AAA"));
+    assertU(adoc("id", "5", "group_s_dv", "B"));
+    assertU(commit());
+    assertU(adoc("id", "3", "group_s_dv", "A"));
+    assertU(commit());
+
+    assertQ(
+        req(
+            "q", "*:*",
+            "fq", "{!collapse field=group_s_dv sort='term_s_dv desc'}",
+            "sort", "id_i asc",
+            "fl", "id"),
+        "*[count(//doc)=2]",
+        "//result/doc[1]/str[@name='id'][.='1']",
+        "//result/doc[2]/str[@name='id'][.='4']");
+
+    assertU(optimize("maxSegments", "1"));
+
+    assertQ(
+        req(
+            "q", "*:*",
+            "fq", "{!collapse field=group_s_dv sort='term_s_dv desc'}",
+            "sort", "id_i asc",
+            "fl", "id"),
+        "*[count(//doc)=2]",
+        "//result/doc[1]/str[@name='id'][.='1']",
+        "//result/doc[2]/str[@name='id'][.='4']");
+  }
 }

Reply via email to