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

jsedding 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 6162b90f53 OAK-9897 - SplitPersistence: FileReaper cannot finish 
cleanup (#665)
6162b90f53 is described below

commit 6162b90f533a41c5824e7a8e44b2448f262cf22a
Author: Julian Sedding <jsedd...@apache.org>
AuthorDate: Mon Sep 1 11:06:27 2025 +0200

    OAK-9897 - SplitPersistence: FileReaper cannot finish cleanup (#665)
---
 .../split/AzureOnTarBaseSplitPersistenceTest.java  | 200 +++++++++++++++
 .../jackrabbit/oak/segment/file/tar/TarReader.java |   8 +
 .../spi/persistence/SegmentArchiveManager.java     |  12 +
 .../split/SplitSegmentArchiveManager.java          |  31 ++-
 .../spi/persistence/split/package-info.java        |   2 +-
 .../persistence/split/SplitPersistenceTest.java    | 251 ++++++++++++++++++
 .../testutils/NodeStoreTestHarness.java            | 285 +++++++++++++++++++++
 .../testutils/PersistenceDecorator.java            |  73 ++++++
 .../testutils/SegmentArchiveManagerDecorator.java  | 107 ++++++++
 9 files changed, 955 insertions(+), 14 deletions(-)

diff --git 
a/oak-segment-azure/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/AzureOnTarBaseSplitPersistenceTest.java
 
b/oak-segment-azure/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/AzureOnTarBaseSplitPersistenceTest.java
new file mode 100644
index 0000000000..8dcbc239cd
--- /dev/null
+++ 
b/oak-segment-azure/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/AzureOnTarBaseSplitPersistenceTest.java
@@ -0,0 +1,200 @@
+/*
+ * 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.segment.spi.persistence.split;
+
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.segment.azure.AzurePersistence;
+import org.apache.jackrabbit.oak.segment.azure.AzuriteDockerRule;
+import org.apache.jackrabbit.oak.segment.file.InvalidFileStoreVersionException;
+import org.apache.jackrabbit.oak.segment.file.tar.TarPersistence;
+import 
org.apache.jackrabbit.oak.segment.spi.persistence.SegmentNodeStorePersistence;
+import 
org.apache.jackrabbit.oak.segment.spi.persistence.testutils.NodeStoreTestHarness;
+import org.apache.jackrabbit.oak.spi.state.ApplyDiff;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.security.InvalidKeyException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class AzureOnTarBaseSplitPersistenceTest {
+
+    @ClassRule
+    public static AzuriteDockerRule azurite = new AzuriteDockerRule();
+
+    @Rule
+    public NodeStoreTestHarness.Rule harnesses = new 
NodeStoreTestHarness.Rule();
+
+    private NodeStoreTestHarness base;
+
+    private NodeStoreTestHarness split;
+
+    @Before
+    public void setup() throws IOException, InvalidFileStoreVersionException, 
CommitFailedException, URISyntaxException, InvalidKeyException {
+        base = harnesses.createHarnessWithFolder(TarPersistence::new);
+        initializeBaseSetup(base, "1");
+        base.getNodeStore().checkpoint(Long.MAX_VALUE);
+        base.setReadOnly();
+
+        SegmentNodeStorePersistence azurePersistence = 
createAzurePersistence("oak");
+        SegmentNodeStorePersistence splitPersistence = new 
SplitPersistence(base.getPersistence(), azurePersistence);
+        split = harnesses.createHarness(splitPersistence);
+    }
+
+    @Test
+    public void baseNodesShouldBeAvailable() {
+        assertBaseSetup(base, "1");
+        assertBaseSetup(split, "1");
+    }
+
+    @Test
+    public void changesShouldBePersistedInAzureStore() throws 
CommitFailedException {
+        modifyNodeStore(split, "2");
+
+        assertBaseSetup(base, "1");
+
+        assertEquals("v2", 
split.getNodeState("/foo/bar").getString("version"));
+        assertEquals("1.0.0", 
split.getNodeState("/foo/bar").getString("fullVersion"));
+        assertEquals("version_1", 
split.getNodeState("/foo").getString("fooVersion"));
+        assertEquals("version_2", 
split.getNodeState("/foo").getString("fooOverwriteVersion"));
+        assertEquals("version_2", 
split.getNodeState("/foo").getString("splitVersion"));
+        assertFalse(split.getNodeState("/foo/to_be_deleted").exists());
+    }
+
+
+    @Test
+    public void rebaseChangesToNewBase() throws CommitFailedException, 
IOException, InvalidFileStoreVersionException {
+
+        modifyNodeStore(split, "2");
+
+        final NodeStoreTestHarness newBase = 
harnesses.createHarnessWithFolder(TarPersistence::new);
+        initializeBaseSetup(newBase, "3");
+        newBase.setReadOnly();
+        assertBaseSetup(newBase, "3");
+
+        SegmentNodeStorePersistence azurePersistence = 
createAzurePersistence("oak-2");
+        SegmentNodeStorePersistence splitPersistence = new 
SplitPersistence(newBase.getPersistence(), azurePersistence);
+        final NodeStoreTestHarness newSplit = 
harnesses.createHarness(splitPersistence);
+        // base -> newBase
+        // azure -> rebase diff base..split (i.e. what's stored in azure) onto 
newBase and write them to newSplit
+        newSplit.writeAndCommit(builder -> {
+            split.getRoot().compareAgainstBaseState(base.getRoot(), new 
ApplyDiff(builder));
+        });
+
+        assertEquals("v2", 
newSplit.getNodeState("/foo/bar").getString("version"));
+        assertEquals("3.0.0", 
newSplit.getNodeState("/foo/bar").getString("fullVersion"));
+        assertEquals("version_3", 
newSplit.getNodeState("/foo").getString("fooVersion"));
+        assertEquals("version_2", 
newSplit.getNodeState("/foo").getString("fooOverwriteVersion"));
+        assertEquals("version_2", 
newSplit.getNodeState("/foo").getString("splitVersion"));
+        assertFalse(split.getNodeState("/foo/to_be_deleted").exists());
+    }
+
+    @Test
+    @Ignore("flaky test")
+    public void rebaseChangesAfterGC() throws CommitFailedException, 
IOException, InvalidFileStoreVersionException, InterruptedException {
+
+        createGarbage();
+        modifyNodeStore(split, "2");
+        assertTrue(split.runGC());
+        split.startNewTarFile();
+
+        final NodeStoreTestHarness newBase = 
harnesses.createHarnessWithFolder(TarPersistence::new);
+        initializeBaseSetup(newBase, "3");
+        newBase.setReadOnly();
+        assertBaseSetup(newBase, "3");
+
+        SegmentNodeStorePersistence azurePersistence = 
createAzurePersistence("oak-2");
+        SegmentNodeStorePersistence splitPersistence = new 
SplitPersistence(newBase.getPersistence(), azurePersistence);
+        final NodeStoreTestHarness newSplit = 
harnesses.createHarness(splitPersistence);
+        // base -> newBase
+        // azure -> rebase diff base..split (i.e. what's stored in azure) onto 
newBase and write them to newSplit
+        newSplit.writeAndCommit(builder -> {
+            split.getRoot().compareAgainstBaseState(base.getRoot(), new 
ApplyDiff(builder));
+        });
+
+        // In case we need more advanced conflict resolution, below code can 
help.
+        // However, I think ApplyDiff is equivalent to an OURS resolution 
strategy.
+        // final NodeBuilder builder = newSplit.getRoot().builder();
+        // split.getRoot().compareAgainstBaseState(base.getRoot(), new 
ConflictAnnotatingRebaseDiff(builder));
+        // newSplit.merge(builder, 
ConflictHook.of(DefaultThreeWayConflictHandler.OURS), CommitInfo.EMPTY);
+
+        assertEquals("v2", 
newSplit.getNodeState("/foo/bar").getString("version"));
+        assertEquals("3.0.0", 
newSplit.getNodeState("/foo/bar").getString("fullVersion"));
+        assertEquals("version_3", 
newSplit.getNodeState("/foo").getString("fooVersion"));
+        assertEquals("version_2", 
newSplit.getNodeState("/foo").getString("fooOverwriteVersion"));
+        assertEquals("version_2", 
newSplit.getNodeState("/foo").getString("splitVersion"));
+        assertFalse(split.getNodeState("/foo/to_be_deleted").exists());
+    }
+
+    private static @NotNull AzurePersistence createAzurePersistence(String 
rootPrefix) {
+        return new AzurePersistence(
+                azurite.getReadBlobContainerClient("oak-test"),
+                azurite.getWriteBlobContainerClient("oak-test"),
+                azurite.getNoRetryBlobContainerClient("oak-test"),
+                rootPrefix
+        );
+    }
+
+    private void createGarbage() throws CommitFailedException, IOException, 
InvalidFileStoreVersionException {
+        for (int i = 0; i < 100; i++) {
+            modifyNodeStore(split, "" + i);
+            if (i % 50 == 0) {
+                split.startNewTarFile();
+            }
+        }
+    }
+
+    private void initializeBaseSetup(NodeStoreTestHarness harness, String 
version) throws CommitFailedException, IOException, 
InvalidFileStoreVersionException {
+        harness.writeAndCommit(builder -> {
+            builder.child("foo").child("bar").setProperty("version", "v" + 
version);
+            builder.child("foo").child("bar").setProperty("fullVersion", 
version + ".0.0");
+            builder.child("foo").setProperty("fooVersion", "version_" + 
version);
+            builder.child("foo").setProperty("fooOverwriteVersion", "version_" 
+ version);
+            builder.child("foo").child("to_be_deleted").setProperty("version", 
"v" + version);
+        });
+        harness.startNewTarFile();
+    }
+
+    private static void assertBaseSetup(NodeStoreTestHarness harness, String 
version) {
+        assertEquals("v" + version, 
harness.getNodeState("/foo/bar").getString("version"));
+        assertEquals(version + ".0.0", 
harness.getNodeState("/foo/bar").getString("fullVersion"));
+        assertEquals("version_" + version, 
harness.getNodeState("/foo").getString("fooVersion"));
+        assertEquals("version_" + version, 
harness.getNodeState("/foo").getString("fooOverwriteVersion"));
+        assertNull(harness.getNodeState("/foo").getString("splitVersion"));
+        assertEquals("v" + version, 
harness.getNodeState("/foo/to_be_deleted").getString("version"));
+    }
+
+    private static void modifyNodeStore(NodeStoreTestHarness harness, String 
version) throws CommitFailedException {
+        harness.writeAndCommit(builder -> {
+            builder.child("foo").child("bar").setProperty("version", "v" + 
version);
+            builder.child("foo").setProperty("fooOverwriteVersion", "version_" 
+ version);
+            builder.child("foo").setProperty("splitVersion", "version_" + 
version);
+            builder.child("foo").child("to_be_deleted").remove();
+        });
+    }
+}
diff --git 
a/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/file/tar/TarReader.java
 
b/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/file/tar/TarReader.java
index c17a4caa32..7d5ca71707 100644
--- 
a/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/file/tar/TarReader.java
+++ 
b/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/file/tar/TarReader.java
@@ -406,6 +406,10 @@ public class TarReader implements Closeable {
      * @param context     An instance of {@link CleanupContext}.
      */
     void mark(Set<UUID> references, Set<UUID> reclaimable, CleanupContext 
context) throws IOException {
+        if (archiveManager.isReadOnly(this.getFileName())) {
+            return;
+        }
+
         SegmentGraph graph = getGraph();
         SegmentArchiveEntry[] entries = getEntries();
         for (int i = entries.length - 1; i >= 0; i--) {
@@ -465,6 +469,10 @@ public class TarReader implements Closeable {
      * TarReader}, or {@code null}.
      */
     TarReader sweep(@NotNull Set<UUID> reclaim, @NotNull Set<UUID> reclaimed) 
throws IOException {
+        if (archiveManager.isReadOnly(this.getFileName())) {
+            return this;
+        }
+
         String name = archive.getName();
         log.debug("Cleaning up {}", name);
 
diff --git 
a/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/SegmentArchiveManager.java
 
b/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/SegmentArchiveManager.java
index 83cd384c0f..37ccd0e1ce 100644
--- 
a/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/SegmentArchiveManager.java
+++ 
b/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/SegmentArchiveManager.java
@@ -127,4 +127,16 @@ public interface SegmentArchiveManager {
      * @throws IOException
      */
     void backup(@NotNull String archiveName, @NotNull String 
backupArchiveName, @NotNull Set<UUID> recoveredEntries) throws IOException;
+
+    /**
+     * Check if the named archive is a read-only archive or not. Read-only 
archives cannot be
+     * modified, renamed or removed. E.g. they can not be deleted by 
compaction even if they
+     * no longer contain any referenced segments.
+     *
+     * @return {@code true} if the named archive is read-only, false otherwise.
+     * @param archiveName The name identifying the archive.
+     */
+    default boolean isReadOnly(String archiveName) {
+        return false;
+    }
 }
diff --git 
a/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/SplitSegmentArchiveManager.java
 
b/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/SplitSegmentArchiveManager.java
index 3a025710e2..cdc1433202 100644
--- 
a/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/SplitSegmentArchiveManager.java
+++ 
b/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/SplitSegmentArchiveManager.java
@@ -21,6 +21,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.UUID;
 
@@ -64,7 +65,7 @@ public class SplitSegmentArchiveManager implements 
SegmentArchiveManager {
 
     @Override
     public @Nullable SegmentArchiveReader open(@NotNull String archiveName) 
throws IOException {
-        if (roArchiveList.contains(archiveName)) {
+        if (isReadOnly(archiveName)) {
             SegmentArchiveReader reader = null;
             try {
                 reader = roArchiveManager.open(archiveName);
@@ -74,7 +75,9 @@ public class SplitSegmentArchiveManager implements 
SegmentArchiveManager {
             if (reader == null) {
                 reader = roArchiveManager.forceOpen(archiveName);
             }
-            return new UnclosedSegmentArchiveReader(reader);
+            return Optional.ofNullable(reader)
+                    .map(UnclosedSegmentArchiveReader::new)
+                    .orElse(null);
         } else {
             return rwArchiveManager.open(archiveName);
         }
@@ -82,7 +85,7 @@ public class SplitSegmentArchiveManager implements 
SegmentArchiveManager {
 
     @Override
     public @Nullable SegmentArchiveReader forceOpen(String archiveName) throws 
IOException {
-        if (roArchiveList.contains(archiveName)) {
+        if (isReadOnly(archiveName)) {
             return roArchiveManager.forceOpen(archiveName);
         } else {
             return rwArchiveManager.forceOpen(archiveName);
@@ -96,7 +99,7 @@ public class SplitSegmentArchiveManager implements 
SegmentArchiveManager {
 
     @Override
     public boolean delete(@NotNull String archiveName) {
-        if (roArchiveList.contains(archiveName)) {
+        if (isReadOnly(archiveName)) {
             return false;
         } else {
             return rwArchiveManager.delete(archiveName);
@@ -105,7 +108,7 @@ public class SplitSegmentArchiveManager implements 
SegmentArchiveManager {
 
     @Override
     public boolean renameTo(@NotNull String from, @NotNull String to) {
-        if (roArchiveList.contains(from) || roArchiveList.contains(to)) {
+        if (isReadOnly(from) || isReadOnly(to)) {
             return false;
         } else {
             return rwArchiveManager.renameTo(from, to);
@@ -114,9 +117,9 @@ public class SplitSegmentArchiveManager implements 
SegmentArchiveManager {
 
     @Override
     public void copyFile(@NotNull String from, @NotNull String to) throws 
IOException {
-        if (roArchiveList.contains(to)) {
+        if (isReadOnly(to)) {
             throw new IOException("Can't overwrite the read-only " + to);
-        } else if (roArchiveList.contains(from)) {
+        } else if (isReadOnly(from)) {
             throw new IOException("Can't copy the archive between persistence 
" + from + " -> " + to);
         } else {
             rwArchiveManager.copyFile(from, to);
@@ -125,12 +128,12 @@ public class SplitSegmentArchiveManager implements 
SegmentArchiveManager {
 
     @Override
     public boolean exists(@NotNull String archiveName) {
-        return roArchiveList.contains(archiveName) || 
rwArchiveManager.exists(archiveName);
+        return isReadOnly(archiveName) || rwArchiveManager.exists(archiveName);
     }
 
     @Override
     public void recoverEntries(@NotNull String archiveName, @NotNull 
LinkedHashMap<UUID, byte[]> entries) throws IOException {
-        if (roArchiveList.contains(archiveName)) {
+        if (isReadOnly(archiveName)) {
             roArchiveManager.recoverEntries(archiveName, entries);
         } else {
             rwArchiveManager.recoverEntries(archiveName, entries);
@@ -139,11 +142,13 @@ public class SplitSegmentArchiveManager implements 
SegmentArchiveManager {
 
     @Override
     public void backup(@NotNull String archiveName, @NotNull String 
backupArchiveName, @NotNull Set<UUID> recoveredEntries) throws IOException {
-        if (roArchiveList.contains(archiveName)) {
-            // archive is in read only part
-            return;
-        } else {
+        if (!isReadOnly(archiveName)) {
             rwArchiveManager.backup(archiveName, backupArchiveName, 
recoveredEntries);
         }
     }
+
+    @Override
+    public boolean isReadOnly(String archiveName) {
+        return roArchiveList.contains(archiveName);
+    }
 }
diff --git 
a/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/package-info.java
 
b/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/package-info.java
index 2e21b7b6b8..fed6db1b31 100644
--- 
a/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/package-info.java
+++ 
b/oak-segment-tar/src/main/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/package-info.java
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 @Internal(since = "1.0.0")
-@Version("1.0.0")
+@Version("1.1.0")
 package org.apache.jackrabbit.oak.segment.spi.persistence.split;
 
 import org.apache.jackrabbit.oak.commons.annotations.Internal;
diff --git 
a/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/SplitPersistenceTest.java
 
b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/SplitPersistenceTest.java
new file mode 100644
index 0000000000..a64321bf88
--- /dev/null
+++ 
b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/split/SplitPersistenceTest.java
@@ -0,0 +1,251 @@
+/*
+ * 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.segment.spi.persistence.split;
+
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.segment.file.InvalidFileStoreVersionException;
+import org.apache.jackrabbit.oak.segment.file.tar.TarPersistence;
+import org.apache.jackrabbit.oak.segment.spi.persistence.JournalFileReader;
+import org.apache.jackrabbit.oak.segment.spi.persistence.SegmentArchiveManager;
+import org.apache.jackrabbit.oak.segment.spi.persistence.SegmentArchiveReader;
+import 
org.apache.jackrabbit.oak.segment.spi.persistence.testutils.NodeStoreTestHarness;
+import org.hamcrest.CoreMatchers;
+import org.hamcrest.MatcherAssert;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.UUID;
+
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class SplitPersistenceTest {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(SplitPersistenceTest.class);
+
+    @Rule
+    public NodeStoreTestHarness.Rule harnesses = new 
NodeStoreTestHarness.Rule();
+
+    private NodeStoreTestHarness splitHarness;
+
+    private SegmentArchiveManager splitArchiveManager;
+
+    private TarPersistence rwPersistence;
+
+    private @NotNull List<String> roArchives;
+
+    @Before
+    public void setUp() throws IOException, InvalidFileStoreVersionException, 
CommitFailedException {
+        final NodeStoreTestHarness roHarness = 
harnesses.createHarnessWithFolder(TarPersistence::new);
+        initializeBaseSetup(roHarness, "1");
+        roHarness.startNewTarFile(); // data00000a.tar
+        modifyNodeStore(roHarness, "2");
+        roHarness.setReadOnly(); // data00001a.tar
+        roArchives = roHarness.createArchiveManager().listArchives();
+
+        splitHarness = harnesses.createHarnessWithFolder(folder -> {
+            try {
+                rwPersistence = new TarPersistence(folder);
+                return new SplitPersistence(roHarness.getPersistence(), 
rwPersistence);
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        });
+        splitArchiveManager = splitHarness.createArchiveManager();
+
+        modifyNodeStore(splitHarness, "2");
+        splitHarness.startNewTarFile(); // data00002a.tar
+        modifyNodeStore(splitHarness, "3");
+        splitHarness.startNewTarFile(); // data00003a.tar
+    }
+
+    @Test
+    public void archiveManager_exists() {
+        assertTrue(splitArchiveManager.exists("data00000a.tar"));
+        assertTrue(splitArchiveManager.exists("data00001a.tar"));
+        assertTrue(splitArchiveManager.exists("data00002a.tar"));
+        assertTrue(splitArchiveManager.exists("data00003a.tar"));
+        assertFalse(splitArchiveManager.exists("data00004a.tar"));
+    }
+
+    @Test
+    public void archiveManager_listArchives() throws IOException {
+        List<String> archiveNames = splitArchiveManager.listArchives();
+        Collections.sort(archiveNames);
+        assertEquals(
+                asList("data00000a.tar", "data00001a.tar", "data00002a.tar", 
"data00003a.tar"),
+                archiveNames);
+    }
+
+    @Test
+    public void archiveManager_open() throws IOException {
+        // open on RO
+        try (SegmentArchiveReader reader = 
splitArchiveManager.open("data00000a.tar")) {
+            assertNotNull(reader);
+        }
+        // open on RW
+        try (SegmentArchiveReader reader = 
splitArchiveManager.open("data00003a.tar")) {
+            assertNotNull(reader);
+        }
+    }
+
+    @Test
+    public void archiveManager_failToOpenROArchive() throws IOException {
+        SegmentArchiveManager ro = mock(SegmentArchiveManager.class);
+        when(ro.listArchives()).thenReturn(new 
ArrayList<>(List.of("data00000a.tar")));
+        when(ro.open(eq("data00000a.tar"))).thenThrow(new IOException());
+        when(ro.forceOpen(eq("data00000a.tar"))).thenReturn(null);
+        SegmentArchiveManager rw = mock(SegmentArchiveManager.class);
+        SplitSegmentArchiveManager split = new SplitSegmentArchiveManager(ro, 
rw, "data00000a.tar");
+        // open fails on RO, returns null
+        try (SegmentArchiveReader reader = split.open("data00000a.tar")) {
+            assertNull(reader);
+        }
+    }
+
+    @Test
+    public void archiveManager_forceOpen() throws IOException {
+        // forceOpen on RO
+        try (SegmentArchiveReader reader = 
splitArchiveManager.forceOpen("data00000a.tar")) {
+            assertNotNull(reader);
+        }
+        // forceOpen on RW
+        try (SegmentArchiveReader reader = 
splitArchiveManager.forceOpen("data00003a.tar")) {
+            assertNotNull(reader);
+        }
+    }
+
+    @Test
+    public void archiveManager_delete() throws IOException {
+        assertFalse(splitArchiveManager.delete("data00000a.tar"));
+        assertTrue(splitArchiveManager.delete("data00003a.tar"));
+    }
+
+    @Test
+    public void archiveManager_renameTo() throws IOException {
+        assertFalse(splitArchiveManager.renameTo("data00000a.tar", 
"data00000a.tar.bak"));
+        assertTrue(splitArchiveManager.renameTo("data00003a.tar", 
"data00003a.tar.bak"));
+    }
+
+    @Test
+    public void archiveManager_copyFile() throws IOException {
+        assertThrows(IOException.class, () -> 
splitArchiveManager.copyFile("data00000a.tar", "data00004a.tar"));
+        assertThrows(IOException.class, () -> 
splitArchiveManager.copyFile("data00003a.tar", "data00000a.tar"));
+        
assertFalse(splitArchiveManager.listArchives().contains("data00004a.tar"));
+        splitArchiveManager.copyFile("data00003a.tar", "data00004a.tar");
+        
assertTrue(splitArchiveManager.listArchives().contains("data00004a.tar"));
+    }
+
+    @Test
+    public void archiveManager_recover_and_backup() throws IOException {
+        LinkedHashMap<UUID, byte[]> entries = new LinkedHashMap<>();
+        splitArchiveManager.recoverEntries("data00000a.tar", entries);
+        assertEquals(2, entries.size());
+        splitArchiveManager.backup("data00000a.tar", "data00000a.tar.bak", 
entries.keySet());
+        assertFalse(splitArchiveManager.exists("data00000a.tar.bak"));
+
+        entries.clear();
+        splitArchiveManager.recoverEntries("data00002a.tar", entries);
+        assertEquals(1, entries.size());
+        splitArchiveManager.backup("data00002a.tar", "data00002a.tar.bak", 
entries.keySet());
+        assertTrue(splitArchiveManager.exists("data00002a.tar.bak"));
+    }
+
+    @Test
+    public void segmentFilesExist() {
+        assertTrue(splitHarness.getPersistence().segmentFilesExist());
+    }
+
+    @Test
+    public void getJournalFile() throws IOException {
+        try (final JournalFileReader rwJournalFileReader = 
rwPersistence.getJournalFile().openJournalReader();
+             final JournalFileReader splitJournalFileReader = 
splitHarness.getPersistence().getJournalFile().openJournalReader()) {
+            assertEquals(rwJournalFileReader.readLine(), 
splitJournalFileReader.readLine());
+        }
+    }
+
+    @Test
+    public void getGCJournalFile() throws IOException {
+        assertEquals(rwPersistence.getGCJournalFile().readLines(), 
splitHarness.getPersistence().getGCJournalFile().readLines());
+    }
+
+    @Test
+    public void getManifestFile() throws IOException {
+        assertEquals(rwPersistence.getManifestFile().load(), 
splitHarness.getPersistence().getManifestFile().load());
+    }
+
+    @Test
+    public void gcOnlyCompactsRWStore() throws IOException, 
CommitFailedException, InvalidFileStoreVersionException, InterruptedException {
+        for (int i = 0; i < 3; i++) {
+            createGarbage();
+            final List<String> archivesBeforeGC = 
splitArchiveManager.listArchives();
+            LOG.info("archives before gc: {}", archivesBeforeGC);
+            assertTrue("GC should run successfully", splitHarness.runGC());
+            final List<String> archivesAfterGC = 
splitArchiveManager.listArchives();
+            LOG.info("archives after gc: {}", archivesAfterGC);
+            MatcherAssert.assertThat(archivesAfterGC, 
CoreMatchers.hasItems(roArchives.toArray(new String[0])));
+        }
+    }
+
+    private static void initializeBaseSetup(NodeStoreTestHarness harness, 
String version) throws CommitFailedException, IOException, 
InvalidFileStoreVersionException {
+        harness.writeAndCommit(builder -> {
+            builder.child("foo").child("bar").setProperty("version", "v" + 
version);
+            builder.child("foo").child("bar").setProperty("fullVersion", 
version + ".0.0");
+            builder.child("foo").setProperty("fooVersion", "version_" + 
version);
+            builder.child("foo").setProperty("fooOverwriteVersion", "version_" 
+ version);
+            builder.child("foo").child("to_be_deleted").setProperty("version", 
"v" + version);
+        });
+        harness.startNewTarFile();
+    }
+
+    private static void modifyNodeStore(NodeStoreTestHarness harness, String 
version) throws CommitFailedException {
+        harness.writeAndCommit(builder -> {
+            builder.child("foo").child("bar").setProperty("version", "v" + 
version);
+            builder.child("foo").setProperty("fooOverwriteVersion", "version_" 
+ version);
+            builder.child("foo").setProperty("splitVersion", "version_" + 
version);
+            builder.child("foo").child("to_be_deleted").remove();
+        });
+    }
+
+    private void createGarbage() throws CommitFailedException, IOException, 
InvalidFileStoreVersionException {
+        for (int i = 0; i < 100; i++) {
+            modifyNodeStore(splitHarness, "" + i);
+            if (i % 50 == 0) {
+                splitHarness.startNewTarFile();
+            }
+        }
+    }
+}
diff --git 
a/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/NodeStoreTestHarness.java
 
b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/NodeStoreTestHarness.java
new file mode 100644
index 0000000000..1f1e0ceb69
--- /dev/null
+++ 
b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/NodeStoreTestHarness.java
@@ -0,0 +1,285 @@
+/*
+ * 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.segment.spi.persistence.testutils;
+
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.segment.SegmentNodeStore;
+import org.apache.jackrabbit.oak.segment.SegmentNodeStoreBuilders;
+import org.apache.jackrabbit.oak.segment.compaction.SegmentGCOptions;
+import org.apache.jackrabbit.oak.segment.file.AbstractFileStore;
+import org.apache.jackrabbit.oak.segment.file.FileStore;
+import org.apache.jackrabbit.oak.segment.file.FileStoreBuilder;
+import org.apache.jackrabbit.oak.segment.file.InvalidFileStoreVersionException;
+import org.apache.jackrabbit.oak.segment.file.ReadOnlyFileStore;
+import org.apache.jackrabbit.oak.segment.spi.monitor.FileStoreMonitorAdapter;
+import org.apache.jackrabbit.oak.segment.spi.monitor.IOMonitorAdapter;
+import org.apache.jackrabbit.oak.segment.spi.monitor.RemoteStoreMonitorAdapter;
+import org.apache.jackrabbit.oak.segment.spi.persistence.SegmentArchiveManager;
+import 
org.apache.jackrabbit.oak.segment.spi.persistence.SegmentNodeStorePersistence;
+import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
+import org.apache.jackrabbit.oak.spi.commit.EmptyHook;
+import org.apache.jackrabbit.oak.spi.gc.DelegatingGCMonitor;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.junit.rules.ExternalResource;
+import org.junit.rules.RuleChain;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+public class NodeStoreTestHarness implements Closeable {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(NodeStoreTestHarness.class);
+
+    /**
+     * The rule provides factory methods for {@code NodeStoreTestHarness} 
instances and manages their lifecycle.
+     */
+    public static final class Rule extends ExternalResource {
+
+        private final TemporaryFolder tempFolderRule = new TemporaryFolder(new 
File("target"));
+
+        private final List<Closeable> closeables = new ArrayList<>();
+
+        @Override
+        public Statement apply(Statement base, Description description) {
+            return RuleChain.outerRule(tempFolderRule)
+                    .around(new ExternalResource() {
+                        @Override
+                        protected void after() {
+                            Collections.reverse(closeables);
+                            closeables.forEach(closeable -> {
+                                try {
+                                    closeable.close();
+                                } catch (IOException e) {
+                                    throw new RuntimeException(e);
+                                }
+                            });
+                            super.after();
+                        }
+                    }).apply(base, description);
+        }
+
+        private void registerCloseable(Closeable closeable) {
+            closeables.add(closeable);
+        }
+
+        public NodeStoreTestHarness createHarness(SegmentNodeStorePersistence 
persistence)
+                throws IOException, InvalidFileStoreVersionException {
+            final NodeStoreTestHarness nodeStoreTestHarness = new 
NodeStoreTestHarness(persistence, tempFolderRule.newFolder(), false);
+            registerCloseable(nodeStoreTestHarness);
+            return nodeStoreTestHarness;
+        }
+
+        public NodeStoreTestHarness createHarnessWithFolder(Function<File, 
SegmentNodeStorePersistence> persistenceFactory)
+                throws IOException, InvalidFileStoreVersionException {
+            final File dummyDirectory = tempFolderRule.newFolder();
+            final NodeStoreTestHarness nodeStoreTestHarness = new 
NodeStoreTestHarness(
+                    persistenceFactory.apply(dummyDirectory),
+                    dummyDirectory,
+                    false
+            );
+            registerCloseable(nodeStoreTestHarness);
+            return nodeStoreTestHarness;
+        }
+    }
+
+    private final File dummyDirectory;
+
+    private boolean readOnly;
+
+    private final SegmentNodeStorePersistence persistence;
+
+    private final List<String> filesToBeDeletedByGcFileReaper = new 
CopyOnWriteArrayList<>();
+
+    private volatile AbstractFileStore fileStore;
+
+    private volatile SegmentNodeStore nodeStore;
+
+    // latch used to wait for tar files to be cleaned up after GC
+    private volatile CountDownLatch gcLatch = new CountDownLatch(0);
+
+    private NodeStoreTestHarness(SegmentNodeStorePersistence persistence, File 
dummyDirectory, boolean readOnly) throws InvalidFileStoreVersionException, 
IOException {
+        this.persistence = new PersistenceDecorator(persistence, 
this::fileDeleted);
+        this.dummyDirectory = dummyDirectory;
+        this.readOnly = readOnly;
+        initializeFileStore();
+    }
+
+    private void fileDeleted(String archiveName) {
+        filesToBeDeletedByGcFileReaper.remove(archiveName);
+        if (filesToBeDeletedByGcFileReaper.isEmpty()) {
+            gcLatch.countDown();
+        }
+    }
+
+    public boolean isReadOnly() {
+        return readOnly;
+    }
+
+    public void setReadOnly() throws InvalidFileStoreVersionException, 
IOException {
+        this.readOnly = true;
+        startNewTarFile(); // closes and re-initializes the file store
+    }
+
+    public SegmentNodeStorePersistence getPersistence() {
+        return persistence;
+    }
+
+    public AbstractFileStore getFileStore() {
+        return fileStore;
+    }
+
+    public SegmentNodeStore getNodeStore() {
+        return nodeStore;
+    }
+
+    public boolean runGC() throws IOException, InterruptedException, 
InvalidFileStoreVersionException {
+        gcLatch = new CountDownLatch(1);
+        try {
+            Optional.of(fileStore)
+                    .filter(FileStore.class::isInstance)
+                    .map(FileStore.class::cast)
+                    .ifPresent(store -> {
+                        try {
+                            store.fullGC();
+                            store.flush();
+                        } catch (IOException e) {
+                            throw new RuntimeException("rethrown as 
unchecked", e);
+                        }
+                    });
+
+        } catch (RuntimeException e) {
+            if (Objects.equals(e.getMessage(), "rethrown as unchecked") && 
e.getCause() instanceof IOException) {
+                throw (IOException) e.getCause();
+            }
+            throw e;
+        }
+
+        try {
+            LOG.info("waiting for file reaper to delete {}", 
filesToBeDeletedByGcFileReaper);
+            startNewTarFile(); // optional: file reaper is triggered on 
close(), so starting a new file this speeds up the test
+            return gcLatch.await(6, TimeUnit.SECONDS); // FileReaper is 
scheduled to run every 5 seconds
+        } finally {
+            LOG.info("finished waiting, gc and file reaper are now complete");
+        }
+    }
+
+    public void startNewTarFile() throws IOException, 
InvalidFileStoreVersionException {
+        try {
+            getFileStore().close();
+        } finally {
+            initializeFileStore();
+        }
+    }
+
+    public void writeAndCommit(Consumer<NodeBuilder> action) throws 
CommitFailedException {
+        final SegmentNodeStore nodeStore = getNodeStore();
+        NodeBuilder builder = nodeStore.getRoot().builder();
+        action.accept(builder);
+        nodeStore.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+    }
+
+    public NodeState getRoot() {
+        return getNodeStore().getRoot();
+    }
+
+    public NodeState getNodeState(String path) {
+        return StreamSupport.stream(PathUtils.elements(path).spliterator(), 
false)
+                .reduce(getRoot(), NodeState::getChildNode, (nodeState, 
nodeState2) -> nodeState);
+    }
+
+    @Override
+    public void close() throws IOException {
+        fileStore.close();
+    }
+
+    private void initializeFileStore() throws 
InvalidFileStoreVersionException, IOException {
+        if (isReadOnly()) {
+            initializeReadOnlyFileStore();
+        } else {
+            initializeReadWriteFileStore();
+        }
+    }
+
+    private void initializeReadWriteFileStore() throws 
InvalidFileStoreVersionException, IOException {
+        final FileStore fileStore = 
FileStoreBuilder.fileStoreBuilder(dummyDirectory)
+                .withGCMonitor(new DelegatingGCMonitor() {
+                    @Override
+                    public void info(String message, Object... arguments) {
+                        // this is a poor man's way to wait for GC completion, 
but probably good enough for a test
+                        if (message.endsWith("cleanup marking files for 
deletion: {}")) {
+                            if (arguments.length == 1 && arguments[0] 
instanceof String) {
+                                final ArrayList<String> localFiles = 
Arrays.stream(((String) arguments[0]).split(","))
+                                        .map(String::trim)
+                                        .filter(name -> !Objects.equals(name, 
"none"))
+                                        
.collect(Collectors.toCollection(ArrayList::new));
+                                if (gcLatch.getCount() > 0) {
+                                    LOG.info("adding files to be deleted {}", 
localFiles);
+                                    
filesToBeDeletedByGcFileReaper.addAll(localFiles);
+                                }
+                                if (filesToBeDeletedByGcFileReaper.isEmpty()) {
+                                    gcLatch.countDown();
+                                }
+                            }
+                        }
+                        super.info(message, arguments);
+                    }
+                })
+                .withCustomPersistence(persistence)
+                .withMaxFileSize(1)
+                .withGCOptions(new 
SegmentGCOptions().setEstimationDisabled(true))
+                .build();
+
+        this.fileStore = fileStore;
+        this.nodeStore = SegmentNodeStoreBuilders.builder(fileStore).build();
+    }
+
+    private void initializeReadOnlyFileStore() throws 
InvalidFileStoreVersionException, IOException {
+        final ReadOnlyFileStore readOnlyFileStore = 
FileStoreBuilder.fileStoreBuilder(dummyDirectory)
+                .withCustomPersistence(persistence)
+                .buildReadOnly();
+        this.fileStore = readOnlyFileStore;
+        this.nodeStore = 
SegmentNodeStoreBuilders.builder(readOnlyFileStore).build();
+    }
+
+    public SegmentArchiveManager createArchiveManager() throws IOException {
+        return getPersistence().createArchiveManager(false, false, new 
IOMonitorAdapter(), new FileStoreMonitorAdapter(), new 
RemoteStoreMonitorAdapter());
+    }
+}
diff --git 
a/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/PersistenceDecorator.java
 
b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/PersistenceDecorator.java
new file mode 100644
index 0000000000..ae8d8a2d37
--- /dev/null
+++ 
b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/PersistenceDecorator.java
@@ -0,0 +1,73 @@
+/*
+ * 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.segment.spi.persistence.testutils;
+
+import org.apache.jackrabbit.oak.segment.spi.monitor.FileStoreMonitor;
+import org.apache.jackrabbit.oak.segment.spi.monitor.IOMonitor;
+import org.apache.jackrabbit.oak.segment.spi.monitor.RemoteStoreMonitor;
+import org.apache.jackrabbit.oak.segment.spi.persistence.GCJournalFile;
+import org.apache.jackrabbit.oak.segment.spi.persistence.JournalFile;
+import org.apache.jackrabbit.oak.segment.spi.persistence.ManifestFile;
+import org.apache.jackrabbit.oak.segment.spi.persistence.RepositoryLock;
+import org.apache.jackrabbit.oak.segment.spi.persistence.SegmentArchiveManager;
+import 
org.apache.jackrabbit.oak.segment.spi.persistence.SegmentNodeStorePersistence;
+
+import java.io.IOException;
+import java.util.function.Consumer;
+
+public class PersistenceDecorator implements SegmentNodeStorePersistence {
+
+    private final SegmentNodeStorePersistence delegate;
+    private final Consumer<String> fileDeletedCallback;
+
+    public PersistenceDecorator(SegmentNodeStorePersistence delegate, 
Consumer<String> fileDeletedCallback) {
+        this.delegate = delegate;
+        this.fileDeletedCallback = fileDeletedCallback;
+    }
+
+    @Override
+    public SegmentArchiveManager createArchiveManager(boolean memoryMapping, 
boolean offHeapAccess, IOMonitor ioMonitor, FileStoreMonitor fileStoreMonitor, 
RemoteStoreMonitor remoteStoreMonitor) throws IOException {
+        return new 
SegmentArchiveManagerDecorator(delegate.createArchiveManager(memoryMapping, 
offHeapAccess, ioMonitor, fileStoreMonitor, remoteStoreMonitor), 
fileDeletedCallback);
+    }
+
+    @Override
+    public boolean segmentFilesExist() {
+        return delegate.segmentFilesExist();
+    }
+
+    @Override
+    public JournalFile getJournalFile() {
+        return delegate.getJournalFile();
+    }
+
+    @Override
+    public GCJournalFile getGCJournalFile() throws IOException {
+        return delegate.getGCJournalFile();
+    }
+
+    @Override
+    public ManifestFile getManifestFile() throws IOException {
+        return delegate.getManifestFile();
+    }
+
+    @Override
+    public RepositoryLock lockRepository() throws IOException {
+        return delegate.lockRepository();
+    }
+}
diff --git 
a/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/SegmentArchiveManagerDecorator.java
 
b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/SegmentArchiveManagerDecorator.java
new file mode 100644
index 0000000000..4d07974d2a
--- /dev/null
+++ 
b/oak-segment-tar/src/test/java/org/apache/jackrabbit/oak/segment/spi/persistence/testutils/SegmentArchiveManagerDecorator.java
@@ -0,0 +1,107 @@
+/*
+ * 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.segment.spi.persistence.testutils;
+
+import org.apache.jackrabbit.oak.segment.spi.persistence.SegmentArchiveManager;
+import org.apache.jackrabbit.oak.segment.spi.persistence.SegmentArchiveReader;
+import org.apache.jackrabbit.oak.segment.spi.persistence.SegmentArchiveWriter;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+public class SegmentArchiveManagerDecorator implements SegmentArchiveManager {
+
+    private final SegmentArchiveManager delegate;
+
+    private final Consumer<String> fileDeletedCallback;
+
+    public SegmentArchiveManagerDecorator(SegmentArchiveManager delegate, 
Consumer<String> fileDeletedCallback) {
+        this.delegate = delegate;
+        this.fileDeletedCallback = fileDeletedCallback;
+    }
+
+    @Override
+    @NotNull
+    public List<String> listArchives() throws IOException {
+        return delegate.listArchives();
+    }
+
+    @Override
+    @Nullable
+    public SegmentArchiveReader open(@NotNull String archiveName) throws 
IOException {
+        return delegate.open(archiveName);
+    }
+
+    @Override
+    @Nullable
+    public SegmentArchiveReader forceOpen(String archiveName) throws 
IOException {
+        return delegate.forceOpen(archiveName);
+    }
+
+    @Override
+    @NotNull
+    public SegmentArchiveWriter create(@NotNull String archiveName) throws 
IOException {
+        return delegate.create(archiveName);
+    }
+
+    @Override
+    public boolean delete(@NotNull String archiveName) {
+        final boolean deleted = delegate.delete(archiveName);
+        if (deleted) {
+            fileDeletedCallback.accept(archiveName);
+        }
+        return deleted;
+    }
+
+    @Override
+    public boolean renameTo(@NotNull String from, @NotNull String to) {
+        return delegate.renameTo(from, to);
+    }
+
+    @Override
+    public void copyFile(@NotNull String from, @NotNull String to) throws 
IOException {
+        delegate.copyFile(from, to);
+    }
+
+    @Override
+    public boolean exists(@NotNull String archiveName) {
+        return delegate.exists(archiveName);
+    }
+
+    @Override
+    public void recoverEntries(@NotNull String archiveName, @NotNull 
LinkedHashMap<UUID, byte[]> entries) throws IOException {
+        delegate.recoverEntries(archiveName, entries);
+    }
+
+    @Override
+    public void backup(@NotNull String archiveName, @NotNull String 
backupArchiveName, @NotNull Set<UUID> recoveredEntries) throws IOException {
+        delegate.backup(archiveName, backupArchiveName, recoveredEntries);
+    }
+
+    @Override
+    public boolean isReadOnly(String archiveName) {
+        return delegate.isReadOnly(archiveName);
+    }
+}

Reply via email to