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); + } +}