Skip to content

Avoid quadratic result ordering when bulkfetching DBRefs - #5265

Open
PriyanshuGagiya wants to merge 1 commit into
spring-projects:mainfrom
PriyanshuGagiya:codex/optimize-dbref-bulk-fetch
Open

PriyanshuGagiya wants to merge 1 commit into
spring-projects:mainfrom
PriyanshuGagiya:codex/optimize-dbref-bulk-fetch

Conversation

@PriyanshuGagiya

@PriyanshuGagiya PriyanshuGagiya commented Oct 16, 2026 •

Copy link
Copy Markdown

Replace repeated document scans with an ID lookup, reducing expected result-ordering work from O(n²) to O(n) while preserving existing matching behavior. Adds two short regression tests; existing tests are unchanged.

Validation: 6,295 tests passed, 50 skipped, zero failures.

References Before (µs/op) After (µs/op)
1 0.135 0.087
10 0.838 0.247
100 42.285 2.293
1,000 4,356.269 28.851

At 1,000 references, local processing was ~151× faster and allocation fell from 364 KB/op to 93 KB/op. JMH 1.37, JDK 24.0.2, three forks; in-memory responses exclude database/network time.

Benchmark source and reproduction instructions

Save these two files outside the repository under /tmp/dbref-bench/src/perf/. These are the exact harness sources used for the reported measurements.

ResolverBenchmark.java

package perf;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import com.mongodb.DBRef;
import org.bson.Document;
import org.bson.types.ObjectId;
import org.openjdk.jmh.annotations.*;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class ResolverBenchmark {
    @Param({"1", "10", "100", "1000"})
    public int size;
    private List<DBRef> references;
    private ResolverFixture fixture;

    @Setup
    public void setup() {
        references = new ArrayList<>();
        List<Document> documents = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            ObjectId id = new ObjectId(String.format("%024x", i));
            references.add(new DBRef("items", id));
            documents.add(new Document("_id", id).append("payload", "sample"));
        }
        List<Document> expected = new ArrayList<>(documents);
        Collections.shuffle(documents, new Random(42));
        fixture = new ResolverFixture(documents);
        if (!fixture.resolver.bulkFetch(references).equals(expected)) {
            throw new AssertionError("Incorrect reference order");
        }
    }

    @Benchmark
    public List<Document> bulkFetch() {
        return fixture.resolver.bulkFetch(references);
    }
}

ResolverFixture.java

package perf;

import java.lang.reflect.Proxy;
import java.util.Collection;
import java.util.List;

import com.mongodb.DBRef;
import com.mongodb.client.FindIterable;
import com.mongodb.client.MongoCollection;
import org.bson.Document;
import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.data.mongodb.core.MongoExceptionTranslator;
import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver;

public class ResolverFixture {
    public final DefaultDbRefResolver resolver;
    public Document query;
    public int calls;

    @SuppressWarnings("unchecked")
    public ResolverFixture(List<Document> documents) {
        FindIterable<Document> cursor = (FindIterable<Document>) Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class<?>[] { FindIterable.class }, (proxy, method, args) -> {
                    if (method.getName().equals("into")) {
                        Collection<Document> target = (Collection<Document>) args[0];
                        target.addAll(documents);
                        return target;
                    }
                    throw new UnsupportedOperationException(method.getName());
                });
        MongoCollection<Document> collection = (MongoCollection<Document>) Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class<?>[] { MongoCollection.class }, (proxy, method, args) -> {
                    if (method.getName().equals("find")) {
                        query = (Document) args[0];
                        calls++;
                        return cursor;
                    }
                    throw new UnsupportedOperationException(method.getName());
                });
        MongoDatabaseFactory factory = (MongoDatabaseFactory) Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class<?>[] { MongoDatabaseFactory.class }, (proxy, method, args) -> {
                    if (method.getName().equals("getExceptionTranslator")) {
                        return new MongoExceptionTranslator();
                    }
                    throw new UnsupportedOperationException(method.getName());
                });
        resolver = new DefaultDbRefResolver(factory) {
            @Override
            protected MongoCollection<Document> getCollection(DBRef reference) {
                return collection;
            }
        };
    }
}

Run the same harness on the PR base and on the proposed change. Build each version before measuring it, keep the benchmark sources unchanged, and use separate result filenames. The classpath must point to the compiled production classes for the version being measured.

From the repository root, after saving the source files above:

mkdir -p /tmp/dbref-bench/classes
./mvnw -pl spring-data-mongodb -DskipTests clean test-compile dependency:build-classpath -Dmdep.outputFile=/tmp/dbref-bench/dependencies.txt
BENCH_CP="$PWD/spring-data-mongodb/target/classes:$(cat /tmp/dbref-bench/dependencies.txt)"
JMH_PROCESSOR="$HOME/.m2/repository/org/openjdk/jmh/jmh-generator-annprocess/1.37/jmh-generator-annprocess-1.37.jar"
javac -cp "$BENCH_CP" -processorpath "$BENCH_CP:$JMH_PROCESSOR" -d /tmp/dbref-bench/classes /tmp/dbref-bench/src/perf/*.java
BENCH_VARIANT=baseline # Set to optimized for the second version.
java -cp "/tmp/dbref-bench/classes:$BENCH_CP" org.openjdk.jmh.Main perf.ResolverBenchmark -wi 4 -i 5 -w 500ms -r 500ms -f 3 -t 1 -jvmArgs '-Xms256m -Xmx256m' -prof gc -rf json -rff "/tmp/dbref-bench/$BENCH_VARIANT.json"

The baseline and optimized resolver sources in the original experiment were compiled into separate directories and placed first on each JVM's classpath; the commands above reproduce the comparison using the corresponding repository versions instead.

@mp911de, could you please review this change?
(I was just exploring the repository to see what was new and came across a performance issue.✌️.)

Signed-off-by: Priyanshu Gagiya <priyanshu.gagiya@sprinklr.com>
@PriyanshuGagiya PriyanshuGagiya changed the title Optimize DBRef bulk-fetch result ordering Optimize DBRef bulkfetch result ordering Oct 16, 2026
@PriyanshuGagiya PriyanshuGagiya changed the title Optimize DBRef bulkfetch result ordering Avoid quadratic result ordering when bulkfetching DBRefs Oct 16, 2026
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Oct 16, 2026
@PriyanshuGagiya

PriyanshuGagiya commented Oct 16, 2026 •

Copy link
Copy Markdown
Author

I also see repeated reference scans during sorting in ReferenceLookupDelegate.ListDocumentReferenceQuery, used for @DocumentReference collections can make it from O(n² log n) to O(n² + n log n)

Opened a separate PR for this optimization: #5272. Benchmark results are included in the description.

@mp911de mp911de self-assigned this Oct 19, 2026
.collect(Collectors.toList());
Map<DbRefId, Document> documentsById = new HashMap<>(result.size());
for (Document document : result) {
documentsById.putIfAbsent(new DbRefId(document.get(BasicMongoPersistentProperty.ID_FIELD_NAME)), document);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have another wrapper around the identifier?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the wrapper to keep the old ObjectUtils.nullSafeEquals() matching behavior. For example, two separate byte[] IDs with the same bytes matched before, but wouldn’t match as plain map keys. I’m not sure we actually get byte[] here, the default driver returns Binary. I kept it to avoid breaking any existing scenario, but can remove it if we’re sure it isn’t needed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The binary-ID test I added uses byte[] directly, so it fails with Map<Object, Document>. Since the default driver returns Binary, should I update the test to use new Binary(new byte[] { 1, 2 }) and remove the wrapper, or keep the wrapper to preserve the previous ObjectUtils.nullSafeEquals() behavior?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mp911de, a quick follow-up when you have a chance: would you prefer keeping the wrapper to preserve the previous matching behavior, or using the identifier directly and updating the test to use Binary?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants