/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2026 3A Systems, LLC. */ package org.opends.server.backends.jeb; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; import static org.forgerock.opendj.config.ConfigurationMock.mockCfg; import static org.forgerock.opendj.ldap.ByteString.valueOfUtf8; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.File; import org.forgerock.opendj.config.server.ConfigException; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.server.config.server.JEBackendCfg; import org.opends.server.DirectoryServerTestCase; import org.opends.server.TestCaseUtils; import org.opends.server.backends.pluggable.spi.AccessMode; import org.opends.server.backends.pluggable.spi.ReadOperation; import org.opends.server.backends.pluggable.spi.ReadableTransaction; import org.opends.server.backends.pluggable.spi.TreeName; import org.opends.server.backends.pluggable.spi.WriteOperation; import org.opends.server.backends.pluggable.spi.WriteableTransaction; import org.opends.server.core.MemoryQuota; import org.opends.server.core.ServerContext; import org.opends.server.extensions.DiskSpaceMonitor; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; /** * Tests what a {@link JEStorage} takes as it opens and gives back when the open fails - the twin * of the same cases on {@code PDBStorageTest}. */ @SuppressWarnings("javadoc") public class JEStorageTest extends DirectoryServerTestCase { private static final String BACKEND_ID = "JEStorageTest"; /** * A parent directory under which the storage's own directory is a regular file, so that the open * fails once its configuration is built - the memory reserved - and before the environment is: * what a backend whose directory the server cannot use meets. */ private static final String BLOCKED_DB_DIRECTORY = BACKEND_ID + "-blocked"; private final TreeName treeName = new TreeName("dc=test", "test"); private ServerContext serverContext; private JEStorage storage; @BeforeClass public static void startServer() throws Exception { TestCaseUtils.startServer(); } @BeforeMethod public void setUp() throws Exception { serverContext = mock(ServerContext.class); when(serverContext.getMemoryQuota()).thenReturn(new MemoryQuota()); when(serverContext.getDiskSpaceMonitor()).thenReturn(mock(DiskSpaceMonitor.class)); storage = new JEStorage(createBackendCfg(), serverContext); // the environment is removed on the way in as well as on the way out: a build whose JVM died never ran // tearDown(), and this class shares a fixed db-directory across methods and across builds storage.removeStorageFiles(); storage.open(AccessMode.READ_WRITE); } @AfterMethod public void tearDown() { closeAndRemove(storage); } /** * Closes the storage and removes its environment, keeping whichever of the two failed first. Removing it * from a finally would let a removal failure replace the close() failure (JLS 14.20.2) - and a close() that * throws is exactly the case the removal is here for. */ private static void closeAndRemove(JEStorage storage) { RuntimeException failure = null; try { storage.close(); } catch (RuntimeException e) { failure = e; } try { storage.removeStorageFiles(); } catch (RuntimeException e) { if (failure == null) { failure = e; } else { failure.addSuppressed(e); } } if (failure != null) { throw failure; } } /** * An open which fails gives back what it took before it failed: the memory it reserved for the * cache, and the listener the constructor registered on the backend configuration. Nothing else * will - a root container does not close a storage which did not open - and a backend whose * directory the server cannot use is enabled again and again, each attempt draining one cache * size. */ @Test public void aStorageWhoseOpenFailedGivesBackWhatItTook() throws Exception { final JEBackendCfg cfg = createBackendCfg(); final JEStorage second = blockedStorage(cfg); final MemoryQuota quota = serverContext.getMemoryQuota(); final long availableBefore = quota.getAvailableMemory(); try { second.open(AccessMode.READ_WRITE); fail("the storage was expected not to open over a directory which is a file"); } catch (ConfigException expected) { // What a backend directory the server cannot use does. } finally { unblock(second); } assertThat(quota.getAvailableMemory()).isEqualTo(availableBefore); verify(cfg).removeJEChangeListener(second); } /** * A storage whose open failed has given everything back already, so closing it afterwards takes * nothing more - {@code BackendImpl.importLDIF} closes the storage of its root container however * the import ended - and does not fail on what the open never got to. */ @Test public void closingAStorageWhoseOpenFailedTakesNothingMore() throws Exception { final JEStorage second = blockedStorage(createBackendCfg()); final MemoryQuota quota = serverContext.getMemoryQuota(); final long availableBefore = quota.getAvailableMemory(); try { second.open(AccessMode.READ_WRITE); fail("the storage was expected not to open over a directory which is a file"); } catch (ConfigException expected) { // What a backend directory the server cannot use does. } finally { unblock(second); } second.close(); assertThat(quota.getAvailableMemory()).isEqualTo(availableBefore); } /** * A storage which is open refuses to open again before it takes anything, and what it holds is * left as it is: the refusal is a guard against a programming error, not a failed open with * something to give back. */ @Test public void openingAnOpenStorageIsRefusedAndTakesNothing() throws Exception { createTree(); final MemoryQuota quota = serverContext.getMemoryQuota(); final long availableBefore = quota.getAvailableMemory(); try { storage.open(AccessMode.READ_WRITE); fail("a storage which is open was expected to refuse to open again"); } catch (IllegalStateException expected) { // The guard against a double open. } assertThat(quota.getAvailableMemory()).isEqualTo(availableBefore); // Still open: a read reaches the environment. assertThat(read("missing")).isNull(); } /** A storage whose directory is a regular file, which no open of it can use. */ private JEStorage blockedStorage(JEBackendCfg cfg) throws Exception { when(cfg.getDBDirectory()).thenReturn(BLOCKED_DB_DIRECTORY); final JEStorage blocked = new JEStorage(cfg, serverContext); final File directory = blocked.getDirectory(); directory.getParentFile().mkdirs(); if (!directory.isFile()) { assertThat(directory.createNewFile()).as("the file in the way of %s", directory).isTrue(); } return blocked; } /** Removes the file in the way of the given storage's directory, and the directory it was made in. */ private static void unblock(JEStorage blocked) { final File directory = blocked.getDirectory(); directory.delete(); directory.getParentFile().delete(); } private void createTree() throws Exception { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(treeName, true); } }); } private ByteString read(final String key) throws Exception { return storage.read(new ReadOperation() { @Override public ByteString run(ReadableTransaction txn) throws Exception { return txn.read(treeName, valueOfUtf8(key)); } }); } private static JEBackendCfg createBackendCfg() { final JEBackendCfg backendCfg = mockCfg(JEBackendCfg.class); when(backendCfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config")); when(backendCfg.getBackendId()).thenReturn(BACKEND_ID); when(backendCfg.getDBDirectory()).thenReturn(BACKEND_ID); when(backendCfg.getDBDirectoryPermissions()).thenReturn("755"); when(backendCfg.getDBCacheSize()).thenReturn(0L); when(backendCfg.getDBCachePercent()).thenReturn(20); when(backendCfg.getDBNumCleanerThreads()).thenReturn(2); when(backendCfg.getDBNumLockTables()).thenReturn(63); return backendCfg; } }