You are viewing a plain text version of this content. The canonical link for it is here.
Posted to oak-commits@jackrabbit.apache.org by st...@apache.org on 2022/09/28 09:21:07 UTC

[jackrabbit-oak] branch trunk updated: OAK-9880 : simplify rgc query (#651)

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

stefanegli pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 9e93c3a865 OAK-9880 : simplify rgc query (#651)
9e93c3a865 is described below

commit 9e93c3a8659c5394693bd3d7ab1b09638d93fe05
Author: stefan-egli <st...@apache.org>
AuthorDate: Wed Sep 28 11:21:00 2022 +0200

    OAK-9880 : simplify rgc query (#651)
    
    * OAK-9880 : simplify rgc query
    
    * OAK-9880 : method renamed to reflect new return type
    
    * OAK-9880 : test case added
    
    * OAK-9880 : added lastDefaultNoBranchDeletionRevs to MongoVersionGCSupport - plus more tests. This will now avoid doing queries for idle clusterNodeIds , something which is more relevant now that we split one of the queries into several, one per clusterNodeId
    
    * OAK-9880 : more asserts to count number of deleteMany calls
    
    * OAK-9880 : remove unused/orphaned method
    
    * OAK-9880 : moved deleteMany counters to CountingMongoCollection
---
 .../document/mongo/MongoVersionGCSupport.java      |  51 ++-
 .../MongoVersionGCSupportDefaultNoBranchTest.java  | 408 +++++++++++++++++++++
 .../document/mongo/MongoTestCollection.java        |   2 +-
 .../plugins/document/mongo/MongoTestDatabase.java  |   4 +-
 .../document/prefetch/CountingMongoCollection.java |  33 ++
 5 files changed, 483 insertions(+), 15 deletions(-)

diff --git a/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoVersionGCSupport.java b/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoVersionGCSupport.java
index 910cf8d4cd..1f8e95a929 100644
--- a/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoVersionGCSupport.java
+++ b/oak-store-document/src/main/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoVersionGCSupport.java
@@ -35,6 +35,7 @@ import static org.apache.jackrabbit.oak.plugins.document.NodeDocument.SplitDocTy
 import static org.apache.jackrabbit.oak.plugins.document.mongo.MongoUtils.hasIndex;
 
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
@@ -82,6 +83,10 @@ public class MongoVersionGCSupport extends VersionGCSupport {
     private final MongoDocumentStore store;
 
     private final BasicDBObject hint;
+
+    /** keeps track of the sweepRev of the last (successful) deletion */
+    private RevisionVector lastDefaultNoBranchDeletionRevs;
+
     /**
      * The batch size for the query of possibly deleted docs.
      */
@@ -197,7 +202,7 @@ public class MongoVersionGCSupport extends VersionGCSupport {
             if (DEFAULT_NO_BRANCH != type) {
                 orClauses.add(Filters.eq(SD_TYPE, type.typeCode()));
             } else {
-                result.add(queryForDefaultNoBranch(sweepRevs, getModifiedInSecs(oldestRevTimeStamp)));
+                result.addAll(queriesForDefaultNoBranch(sweepRevs, getModifiedInSecs(oldestRevTimeStamp)));
             }
         }
         // OAK-8351: this (last) query only contains SD_TYPE and SD_MAX_REV_TIME_IN_SECS
@@ -211,11 +216,23 @@ public class MongoVersionGCSupport extends VersionGCSupport {
     }
 
     @NotNull
-    private Bson queryForDefaultNoBranch(RevisionVector sweepRevs, long maxRevTimeInSecs) {
+    private List<Bson> queriesForDefaultNoBranch(RevisionVector sweepRevs, long maxRevTimeInSecs) {
         // default_no_branch split type is special because we can
         // only remove those older than sweep rev
-        List<Bson> orClauses = Lists.newArrayList();
+        List<Bson> result = Lists.newArrayList();
         for (Revision r : sweepRevs) {
+            if (lastDefaultNoBranchDeletionRevs != null) {
+                Revision dr = lastDefaultNoBranchDeletionRevs.getRevision(r.getClusterId());
+                if (dr != null) {
+                    if (dr.getTimestamp() == r.getTimestamp()) {
+                        // implies for this clusterNodeId the sweepRev is in control wrt RGC
+                        // and we've already deleted up to that point.
+                        // and meanwhile the sweepRev for that clusterNodeId hasn't changed.
+                        // so we can skip it
+                        continue;
+                    }
+                }
+            }
             String idSuffix = Utils.getPreviousIdFor(Path.ROOT, r, 0);
             idSuffix = idSuffix.substring(idSuffix.lastIndexOf('-'));
 
@@ -229,16 +246,14 @@ public class MongoVersionGCSupport extends VersionGCSupport {
                     )
             );
 
-            orClauses.add(Filters.and(
-                    idPathClause,
-                    Filters.lt(SD_MAX_REV_TIME_IN_SECS, getModifiedInSecs(r.getTimestamp()))
-            ));
+            long minMaxRevTimeInSecs = Math.min(maxRevTimeInSecs, getModifiedInSecs(r.getTimestamp()));
+            result.add(Filters.and(
+                    Filters.eq(SD_TYPE, DEFAULT_NO_BRANCH.typeCode()),
+                    Filters.lt(SD_MAX_REV_TIME_IN_SECS, minMaxRevTimeInSecs),
+                    idPathClause
+                    ));
         }
-        return Filters.and(
-                Filters.eq(SD_TYPE, DEFAULT_NO_BRANCH.typeCode()),
-                Filters.lt(SD_MAX_REV_TIME_IN_SECS, maxRevTimeInSecs),
-                Filters.or(orClauses)
-                );
+        return result;
     }
 
     private void logSplitDocIdsTobeDeleted(Bson query) {
@@ -301,6 +316,18 @@ public class MongoVersionGCSupport extends VersionGCSupport {
             for (Bson query : queries) {
                 cnt += getNodeCollection().deleteMany(query).getDeletedCount();
             }
+            // remember the revisions up to which deletion happened
+            Set<Revision> deletionRevs = new HashSet<>();
+            for (Revision r : sweepRevs) {
+                if (r.getTimestamp() <= oldestRevTimeStamp) {
+                    // then sweepRev of this clusterNodeId was taking effect: we could only delete up to sweepRev
+                    deletionRevs.add(r);
+                } else {
+                    // then sweepRev is newer than oldestRevTimeStamp, so we could delete up to oldestRevTimeStamp
+                    deletionRevs.add(new Revision(oldestRevTimeStamp, 0, r.getClusterId()));
+                }
+            }
+            MongoVersionGCSupport.this.lastDefaultNoBranchDeletionRevs = new RevisionVector(deletionRevs);
             return cnt;
         }
     }
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/MongoVersionGCSupportDefaultNoBranchTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/MongoVersionGCSupportDefaultNoBranchTest.java
new file mode 100644
index 0000000000..234718f18a
--- /dev/null
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/MongoVersionGCSupportDefaultNoBranchTest.java
@@ -0,0 +1,408 @@
+/*
+ * 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.jackrabbit.oak.plugins.document;
+
+import static org.apache.jackrabbit.oak.plugins.document.Collection.NODES;
+import static org.apache.jackrabbit.oak.plugins.document.DocumentStoreFixture.MONGO;
+import static org.apache.jackrabbit.oak.plugins.document.NodeDocument.SplitDocType.COMMIT_ROOT_ONLY;
+import static org.apache.jackrabbit.oak.plugins.document.NodeDocument.SplitDocType.DEFAULT_LEAF;
+import static org.apache.jackrabbit.oak.plugins.document.NodeDocument.SplitDocType.DEFAULT_NO_BRANCH;
+import static org.apache.jackrabbit.oak.plugins.document.SplitOperations.forDocument;
+import static org.apache.jackrabbit.oak.plugins.document.util.Utils.getIdFromPath;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.document.DocumentMK.Builder;
+import org.apache.jackrabbit.oak.plugins.document.DocumentStoreFixture.MongoFixture;
+import org.apache.jackrabbit.oak.plugins.document.NodeDocument.SplitDocType;
+import org.apache.jackrabbit.oak.plugins.document.VersionGarbageCollector.VersionGCStats;
+import org.apache.jackrabbit.oak.plugins.document.mongo.MongoDocumentStore;
+import org.apache.jackrabbit.oak.plugins.document.mongo.MongoDocumentStoreTestHelper;
+import org.apache.jackrabbit.oak.plugins.document.mongo.MongoTestUtils;
+import org.apache.jackrabbit.oak.plugins.document.mongo.MongoVersionGCSupport;
+import org.apache.jackrabbit.oak.plugins.document.prefetch.CountingMongoDatabase;
+import org.apache.jackrabbit.oak.plugins.document.util.MongoConnection;
+import org.apache.jackrabbit.oak.plugins.document.util.Utils;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.apache.jackrabbit.oak.stats.Clock;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import com.google.common.base.Function;
+import com.google.common.base.Predicate;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.Lists;
+import com.mongodb.ReadPreference;
+
+@RunWith(Parameterized.class)
+public class MongoVersionGCSupportDefaultNoBranchTest {
+
+    private static class Stats {
+        private final VersionGCStats versionGCStats;
+        private final int nodesDeleteMany;
+        Stats(VersionGCStats stats, int nodesDeleteMany) {
+            if (stats == null) {
+                throw new IllegalArgumentException("stats must not be null");
+            }
+            if (nodesDeleteMany < 0) {
+                throw new IllegalArgumentException("nodesDeleteMany must be positive");
+            }
+            this.versionGCStats = stats;
+            this.nodesDeleteMany = nodesDeleteMany;
+        }
+    }
+
+    private static Predicate<NodeDocument> splitDocsWithClusterId(final int clusterId) {
+        return new Predicate<NodeDocument>() {
+            @Override
+            public boolean apply(@Nullable NodeDocument doc) {
+                if (!Utils.isPreviousDocId(doc.getId())) {
+                    return false;
+                }
+                Path p = doc.getPath();
+                p = p.getAncestor(1);
+                Revision rev = Revision.fromString(p.getName());
+                return rev.getClusterId() == clusterId;
+            }
+        };
+    }
+
+    private static final Set<NodeDocument.SplitDocType> GC_TYPES = EnumSet.of(
+            DEFAULT_LEAF, COMMIT_ROOT_ONLY, DEFAULT_NO_BRANCH);
+
+    class MongoVersionGCSupportAccessor extends MongoVersionGCSupport {
+
+        public MongoVersionGCSupportAccessor(MongoDocumentStore store) {
+            super(store);
+        }
+
+        protected Iterable<NodeDocument> identifyGarbage(Set<SplitDocType> gcTypes,
+                RevisionVector sweepRevs, long oldestRevTimeStamp) {
+            return super.identifyGarbage(gcTypes, sweepRevs, oldestRevTimeStamp);
+        }
+    }
+    // using AbstractTwoNodeTest as a helper rather than subclassing to simplify things
+    private AbstractTwoNodeTest helper;
+    private DocumentStoreFixture fixture;
+    private DocumentStore store1;
+    private DocumentStore store2;
+    protected DocumentNodeStore ds1;
+    protected DocumentNodeStore ds2;
+    private VersionGCSupport gcSupport1;
+    private CountingMongoDatabase db;
+    private List<String> ids = Lists.newArrayList();
+
+    private Clock clock;
+    private AtomicInteger offset = new AtomicInteger(0);
+
+    @Parameterized.Parameters(name="{0}")
+    public static java.util.Collection<DocumentStoreFixture> fixtures() {
+        List<DocumentStoreFixture> fixtures = Lists.newArrayList();
+        if (MONGO.isAvailable()) {
+            fixtures.add(new MongoFixture() {
+                @Override
+                public DocumentStore createDocumentStore(Builder builder) {
+                    try {
+                        MongoConnection connection = MongoUtils.getConnection();
+                        CountingMongoDatabase db = new CountingMongoDatabase(connection.getDatabase());
+                        return new MongoDocumentStore(connection.getMongoClient(),db, builder);
+                    } catch (Exception e) {
+                        throw new RuntimeException(e);
+                    }
+                }
+            });
+        }
+        return fixtures;
+    }
+
+    public MongoVersionGCSupportDefaultNoBranchTest(final DocumentStoreFixture fixture) throws InterruptedException {
+        this.fixture = fixture;
+        helper = new AbstractTwoNodeTest(fixture);
+        helper.setUp();
+        this.store1 = helper.store1;
+        this.store2 = helper.store2;
+        this.clock = helper.clock;
+        this.ds1 = helper.ds1;
+        this.ds2 = helper.ds2;
+
+        // Enforce primary read preference, otherwise tests may fail on a
+        // replica set with a read preference configured to secondary.
+        // Revision GC usually runs with a modified range way in the past,
+        // which means changes made it to the secondary, but not in this
+        // test using a virtual clock
+        MongoTestUtils.setReadPreference(store1, ReadPreference.primary());
+        MongoTestUtils.setReadPreference(store2, ReadPreference.primary());
+
+        if (store1 instanceof MongoDocumentStore) {
+            MongoDocumentStore mds = (MongoDocumentStore) store1;
+            this.gcSupport1 = new MongoVersionGCSupportAccessor(mds);
+            this.db = (CountingMongoDatabase) MongoDocumentStoreTestHelper.getDB(mds);
+        } else {
+            this.gcSupport1 = new VersionGCSupport(store1);
+        }
+
+        resetNodesDeleteMany();
+    }
+
+    private int resetNodesDeleteMany() {
+        if (this.db != null) {
+            return this.db.getCachedCountingCollection("nodes").resetNodesDeleteMany();
+        } else {
+            return 0;
+        }
+    }
+
+    private int getNodesDeleteMany() {
+        if (this.db != null) {
+            return this.db.getCachedCountingCollection("nodes").getNodesDeleteMany();
+        } else {
+            return 0;
+        }
+    }
+
+    @After
+    public void after() throws Exception {
+        store1.remove(Collection.NODES, ids);
+        helper.tearDown();
+        fixture.dispose();
+    }
+
+    public Stats deleteSplitDocuments(VersionGCSupport gcSupport, RevisionVector sweepRevs, long oldestRevTimeStamp) {
+        int a = getNodesDeleteMany();
+        VersionGCStats stats = new VersionGCStats();
+        ((VersionGCSupport)gcSupport).deleteSplitDocuments(GC_TYPES, sweepRevs, oldestRevTimeStamp, stats);;
+        int b = getNodesDeleteMany() - a;
+        return new Stats(stats, b);
+    }
+
+    @Test
+    public void testBothInstancesSplit_none_none() throws Exception {
+        doTestBothInstancesSplit(0, 0);
+    }
+
+    @Test
+    public void testBothInstancesSplit_none_one() throws Exception {
+        doTestBothInstancesSplit(0, 1);
+    }
+
+    @Test
+    public void testBothInstancesSplit_one_none() throws Exception {
+        doTestBothInstancesSplit(1, 0);
+    }
+
+    @Test
+    public void testBothInstancesSplit_two_three() throws Exception {
+        doTestBothInstancesSplit(2, 3);
+        assertEquals(4, getNodesDeleteMany());
+    }
+
+    @Test
+    public void testBothInstancesSplit_three_two() throws Exception {
+        doTestBothInstancesSplit(3, 2);
+        assertEquals(4, getNodesDeleteMany());
+    }
+
+    @Test
+    public void testBothInstancesSplit_many_many() throws Exception {
+        doTestBothInstancesSplit(7, 9);
+        assertEquals(4, getNodesDeleteMany());
+    }
+
+    public void doTestBothInstancesSplit(int numSplit1, int numSplit2) throws Exception {
+        final int totalSplits = numSplit1 + numSplit2;
+
+        waitALittle();
+
+        modify(ds1, (b) -> b.child("foo"));
+
+        waitALittle();
+
+        assertNumSplitDocs(store1, "/foo", 0);
+        assertNumSplitDocs(store2, "/foo", 0);
+
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+
+        for(int i = 0; i < numSplit1; i++) {
+            splitPathNoBranch(ds1, "/foo", ds2);
+        }
+        for(int i = 0; i < numSplit2; i++) {
+            splitPathNoBranch(ds2, "/foo", ds1);
+        }
+
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+
+        waitALittle();
+        if (numSplit1 > 0) {
+            modify(ds1, (b) -> b.setProperty("sweeptrigger", offset.getAndIncrement()));
+        }
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+        if (numSplit2 > 0) {
+            modify(ds2, (b) -> b.setProperty("sweeptrigger", offset.getAndIncrement()));
+        }
+        ds2.runBackgroundOperations();
+        ds1.runBackgroundOperations();
+        long oldestRevTimeStamp = clock.getTime();
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+
+        assertNumSplitDocs(store1, "/foo", totalSplits);
+        assertNumSplitDocs(store2, "/foo", totalSplits);
+
+        RevisionVector sweepRevs = ds1.getSweepRevisions();
+        Iterable<NodeDocument> garbage = gcSupport1.identifyGarbage(GC_TYPES, sweepRevs, oldestRevTimeStamp);
+        assertNotNull(garbage);
+        assertEquals(totalSplits, Iterables.size(garbage));
+        assertEquals(numSplit1, Iterables.size(Iterables.filter(garbage, splitDocsWithClusterId(1))));
+        assertEquals(numSplit2, Iterables.size(Iterables.filter(garbage, splitDocsWithClusterId(2))));
+
+        Stats stats = deleteSplitDocuments(gcSupport1, sweepRevs, oldestRevTimeStamp);
+        assertNotNull(stats);
+        assertEquals(totalSplits, stats.versionGCStats.splitDocGCCount);
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+        assertNumSplitDocs(store1, "/foo", 0);
+        assertNumSplitDocs(store2, "/foo", 0);
+
+        waitALittle();
+        oldestRevTimeStamp = clock.getTime();
+        sweepRevs = ds1.getSweepRevisions();
+        stats = deleteSplitDocuments(gcSupport1, sweepRevs, oldestRevTimeStamp);
+        assertNotNull(stats);
+        assertEquals(0, stats.versionGCStats.splitDocGCCount);
+        assertEquals(1, stats.nodesDeleteMany);
+    }
+
+    public void modify(DocumentNodeStore ds, Function<NodeBuilder,NodeBuilder> builderFunction) throws CommitFailedException {
+        NodeBuilder builder = ds.getRoot().builder();
+        builderFunction.apply(builder);
+        TestUtils.merge(ds, builder);
+    }
+
+    @Test
+    public void testLastDefaultNoBranchDeletionRevs() throws Exception {
+        final int NUM_BOTH_SPLITS = 10;
+        for(int i = 0; i < NUM_BOTH_SPLITS; i++) {
+            doTestBothInstancesSplit(1,2);
+        }
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+        int removesCount = resetNodesDeleteMany();
+        // there should be 4 deleteMany calls : first call results in 3, second in 1 => 4
+        assertEquals(4 * NUM_BOTH_SPLITS, removesCount);
+        for(int i = 0; i < 10; i++) {
+            doTestBothInstancesSplit(1,0);
+        }
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+        removesCount = resetNodesDeleteMany();
+        // there should be between 3 and 4 deleteMany calls due to avoiding calls if nothing changed
+        // but seems every other (more or less) iteration there is still a change in _lastRev
+        // so the condition is strict >3 and <4
+        assertTrue(3 * NUM_BOTH_SPLITS < removesCount);
+        assertTrue(4 * NUM_BOTH_SPLITS > removesCount);
+        for(int i = 0; i < 10; i++) {
+            doTestBothInstancesSplit(0, 1);
+        }
+        ds1.runBackgroundOperations();
+        ds2.runBackgroundOperations();
+        removesCount = resetNodesDeleteMany();
+        // there should be between 3 and 4 deleteMany calls due to avoiding calls if nothing changed
+        // but seems every other (more or less) iteration there is still a change in _lastRev
+        // so the condition is strict >3 and <4
+        assertTrue(3 * NUM_BOTH_SPLITS < removesCount);
+        assertTrue(4 * NUM_BOTH_SPLITS > removesCount);
+
+        assertNumSplitDocs(store1, "/foo", 0);
+        assertNumSplitDocs(store2, "/foo", 0);
+
+        waitALittle();
+        long oldestRevTimeStamp = clock.getTime();
+        @NotNull
+        RevisionVector sweepRevs = ds1.getSweepRevisions();
+        Stats stats = deleteSplitDocuments(gcSupport1, sweepRevs, oldestRevTimeStamp);
+        assertNotNull(stats);
+        assertEquals(0, stats.versionGCStats.splitDocGCCount);
+        assertEquals(1, stats.nodesDeleteMany);
+    }
+
+    private void waitALittle() throws InterruptedException {
+        clock.waitUntil(clock.getTime() + TimeUnit.SECONDS.toMillis(NodeDocument.MODIFIED_IN_SECS_RESOLUTION * 2));
+    }
+
+    private void assertNumSplitDocs(DocumentStore ds, String path, int expected) throws Exception {
+        assertEquals(expected, getNumSplitDocuments(ds, path));
+    }
+
+    private void splitPathNoBranch(DocumentNodeStore ns, String path, DocumentNodeStore... otherStores)
+            throws Exception {
+        NodeBuilder builder = ns.getRoot().builder();
+        NodeBuilder nb = builder;
+        for (String name : PathUtils.elements(path)) {
+            nb = nb.child(name);
+        }
+        nb.child("noBranchChild"); // DEFAULT_NO_BRANCH requires a child, otherwise it could be DEFAULT_LEAF
+        nb.setProperty("o", offset.getAndIncrement());
+        TestUtils.merge(ns, builder);
+        nb.setProperty("o", offset.getAndIncrement());
+        TestUtils.merge(ns, builder);
+        nb.setProperty("o", offset.getAndIncrement());
+        TestUtils.merge(ns, builder);
+
+        DocumentStore store = ns.getDocumentStore();
+        NodeDocument doc = store.find(NODES, getIdFromPath(path));
+        List<UpdateOp> ops = forDocument(doc, ns, ns.getHeadRevision(),
+                TestUtils.NO_BINARY, 2);
+        assertFalse(ops.isEmpty());
+        store.createOrUpdate(NODES, ops);
+
+        ns.runBackgroundOperations();
+        for (DocumentNodeStore dns : otherStores) {
+            dns.runBackgroundOperations();
+        }
+    }
+
+    private static int getNumSplitDocuments(DocumentStore store, String path)
+            throws Exception {
+        NodeDocument doc = store.find(NODES, getIdFromPath(path), -1);
+        assertNotNull(doc);
+        return Iterators.size(doc.getAllPreviousDocs());
+    }
+}
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoTestCollection.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoTestCollection.java
index 64940da187..051f99d49d 100644
--- a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoTestCollection.java
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoTestCollection.java
@@ -67,7 +67,7 @@ public class MongoTestCollection<TDocument> implements MongoCollection<TDocument
     private final AtomicReference<String> beforeUpdateException;
     private final AtomicReference<String> afterUpdateException;
 
-    protected MongoTestCollection(MongoCollection<TDocument> collection,
+    public MongoTestCollection(MongoCollection<TDocument> collection,
                         AtomicReference<String> beforeQueryException,
                         AtomicReference<String> beforeUpdateException,
                         AtomicReference<String> afterUpdateException) {
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoTestDatabase.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoTestDatabase.java
index b48f6e93de..27d89cffc9 100644
--- a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoTestDatabase.java
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/mongo/MongoTestDatabase.java
@@ -37,7 +37,7 @@ import org.bson.codecs.configuration.CodecRegistry;
 import org.bson.conversions.Bson;
 import org.jetbrains.annotations.NotNull;
 
-class MongoTestDatabase implements MongoDatabase {
+public class MongoTestDatabase implements MongoDatabase {
 
     private final MongoDatabase db;
 
@@ -45,7 +45,7 @@ class MongoTestDatabase implements MongoDatabase {
     private final AtomicReference<String> beforeUpdateException;
     private final AtomicReference<String> afterUpdateException;
 
-    MongoTestDatabase(MongoDatabase db,
+    public MongoTestDatabase(MongoDatabase db,
                       AtomicReference<String> beforeQueryException,
                       AtomicReference<String> beforeUpdateException,
                       AtomicReference<String> afterUpdateException) {
diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/prefetch/CountingMongoCollection.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/prefetch/CountingMongoCollection.java
index b79a7b211a..55bdc0b267 100644
--- a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/prefetch/CountingMongoCollection.java
+++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/prefetch/CountingMongoCollection.java
@@ -33,6 +33,7 @@ public class CountingMongoCollection<TDocument> extends MongoTestCollection<TDoc
 
     private final MongoCollection<TDocument> countingCollectionDelegate;
     private AtomicInteger findCounter = new AtomicInteger(0);
+    private AtomicInteger nodesDeleteMany = new AtomicInteger(0);
 
     CountingMongoCollection(MongoCollection<TDocument> collection,
             AtomicReference<String> beforeQueryException,
@@ -62,6 +63,38 @@ public class CountingMongoCollection<TDocument> extends MongoTestCollection<TDoc
         findCounter.incrementAndGet();
     }
 
+    public int getNodesDeleteMany() {
+        return nodesDeleteMany.get();
+    }
+
+    public void incNodesDeleteMany() {
+        nodesDeleteMany.incrementAndGet();
+    }
+
+    public int resetNodesDeleteMany() {
+        return nodesDeleteMany.getAndSet(0);
+    }
+
+    public com.mongodb.client.result.DeleteResult deleteMany(org.bson.conversions.Bson filter) {
+        incNodesDeleteMany();
+        return countingCollectionDelegate.deleteMany(filter);
+    };
+
+    public com.mongodb.client.result.DeleteResult deleteMany(org.bson.conversions.Bson filter, com.mongodb.client.model.DeleteOptions options) {
+        incNodesDeleteMany();
+        return countingCollectionDelegate.deleteMany(filter, options);
+    };
+
+    public com.mongodb.client.result.DeleteResult deleteMany(com.mongodb.client.ClientSession clientSession, org.bson.conversions.Bson filter) {
+        incNodesDeleteMany();
+        return countingCollectionDelegate.deleteMany(clientSession, filter);
+    };
+
+    public com.mongodb.client.result.DeleteResult deleteMany(com.mongodb.client.ClientSession clientSession, org.bson.conversions.Bson filter, com.mongodb.client.model.DeleteOptions options) {
+        incNodesDeleteMany();
+        return countingCollectionDelegate.deleteMany(clientSession, filter, options);
+    };
+
     @NotNull
     @Override
     public FindIterable<TDocument> find() {