diff --git a/changelog/unreleased/SOLR-18304-fix-collapse-on-string-performance.yml b/changelog/unreleased/SOLR-18304-fix-collapse-on-string-performance.yml
new file mode 100644
index 000000000000..61ac4b856b6b
--- /dev/null
+++ b/changelog/unreleased/SOLR-18304-fix-collapse-on-string-performance.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 000000000000..403853b2b3dc
--- /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 b1851c3c2906..e3f77a826047 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 @@
+
id
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 ae3102e41ced..2af6c03a920f 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 @@ private static class SortFieldsCompare {
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 SortFieldsCompare(SortField[] sorts, int initNumGroups) {
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 SortFieldsCompare(SortField[] sorts, int initNumGroups) {
: 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 void setNullGroupValues(int contextDoc) throws IOException {
/**
* 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 @@ private boolean testAndSetGroupValues(Object[] values, int contextDoc) throws IO
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 @@ private boolean testAndSetGroupValues(Object[] values, int contextDoc) throws IO
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));
}
diff --git a/solr/core/src/java/org/apache/solr/search/LazyStringValue.java b/solr/core/src/java/org/apache/solr/search/LazyStringValue.java
new file mode 100644
index 000000000000..3b4c7aae18a8
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/search/LazyStringValue.java
@@ -0,0 +1,41 @@
+/*
+ * 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.search;
+
+import java.io.IOException;
+import org.apache.lucene.index.SortedDocValues;
+import org.apache.lucene.util.BytesRef;
+
+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);
+ }
+}
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 716a4a129bc4..7c193f87a735 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 void testNullGroupNumericVsStringCollapse() {
}
}
}
+
+ @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']");
+ }
}