diff --git a/pom.xml b/pom.xml index 5c1501c1..f05984e4 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,7 @@ 1.28.0 33.5.0-jre 0.8.14 + 1.37 0.10.4 2.2.18 5.14.1 @@ -125,6 +126,18 @@ ${mockito.version} test + + org.openjdk.jmh + jmh-core + ${jmh.version} + test + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + test + diff --git a/src/main/java/org/lmdbjava/AbstractFlagSet.java b/src/main/java/org/lmdbjava/AbstractFlagSet.java index 2e917515..9eac7ac6 100644 --- a/src/main/java/org/lmdbjava/AbstractFlagSet.java +++ b/src/main/java/org/lmdbjava/AbstractFlagSet.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -85,6 +85,18 @@ public String toString() { return FlagSet.asString(this); } + @Override + public boolean equals(Object object) { + if (object == null || getClass() != object.getClass()) return false; + AbstractFlagSet that = (AbstractFlagSet) object; + return mask == that.mask && Objects.equals(flags, that.flags); + } + + @Override + public int hashCode() { + return Objects.hash(flags, mask); + } + static class AbstractEmptyFlagSet implements FlagSet { @Override diff --git a/src/main/java/org/lmdbjava/Cursor.java b/src/main/java/org/lmdbjava/Cursor.java index 0e320930..6bb356f8 100644 --- a/src/main/java/org/lmdbjava/Cursor.java +++ b/src/main/java/org/lmdbjava/Cursor.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,60 +30,99 @@ import static org.lmdbjava.SeekOp.MDB_NEXT; import static org.lmdbjava.SeekOp.MDB_PREV; +import java.util.concurrent.atomic.AtomicBoolean; import jnr.ffi.Pointer; import jnr.ffi.byref.NativeLongByReference; /** - * A cursor handle. + * A cursor handle for iterating through key/value pairs in an LMDB database. + * + *

A cursor belongs to a {@link Txn}. + * + *

If {@link Txn} is a read-write transaction, LMDB will automatically close the cursor handle + * when the {@link Txn} is committed or aborted, meaning that Cursor#close() does not need to be + * called, however, if it is called, it must be called before the {@link Txn} is committed/aborted. + * + *

NOTE: If {@link Env.Builder#setSafeClose()} is set, the {@link Env} requires that all cursors + * are closed before the {@link Env} is closed, therefore it is good practice to explicitly call + * {@link Cursor#close()} or use a try-with-resources block on all types of cursor. + * + *

If {@link Txn} is a read-only transaction, {@link Cursor#close()} must be called to free up + * the cursor handle. This can be called at any time. Read-only transactions can 'moved' to a + * different transaction using the {@link Cursor#renew(Txn)} method. This can also be done at any + * time. * * @param buffer type */ public final class Cursor implements AutoCloseable { - private boolean closed; + private final AtomicBoolean closed; private final KeyVal kv; private final Pointer ptrCursor; - private Txn txn; private final Env env; + private final RefCounter.RefCounterReleaser refCounterReleaser; + private volatile Txn txn; Cursor(final Pointer ptr, final Txn txn, final Env env) { requireNonNull(ptr); requireNonNull(txn); + requireNonNull(env); this.ptrCursor = ptr; this.txn = txn; - this.kv = txn.newKeyVal(); + // The env needs to track open RW cursors to prevent env closure before the cursors are closed. + // We don't care about RO cursors as LMDB will automatically free them. + refCounterReleaser = txn.isWritable() ? env.acquire() : null; this.env = env; + this.closed = new AtomicBoolean(false); + try { + this.kv = txn.newKeyVal(); + } catch (final Exception e) { + closed.set(true); + releaseRefCount(); + + // Clean up the native cursor + if (txn.isReadOnly() || txn.isReady()) { + LIB.mdb_cursor_close(ptrCursor); + } + throw e; + } } /** * Close a cursor handle. * *

The cursor handle will be freed and must not be used again after this call. Its transaction - * must still be live if it is a write-transaction. + * must still be live (i.e. not committed or aborted) if it is a write-transaction. */ @Override public void close() { - if (closed) { - return; - } - kv.close(); - if (SHOULD_CHECK) { - env.checkNotClosed(); - if (!txn.isReadOnly()) { - txn.checkReady(); + if (closed.compareAndSet(false, true)) { + kv.close(); + if (SHOULD_CHECK) { + env.checkNotClosed(); + if (!txn.isReadOnly()) { + // TODO Rather than throwing if the txn is not in the right state to close + // we could check the txn state and only call mdb_cursor_close if the state is + // appropriate, + // i.e. (txn.isReadOnly() || txn.isReady()) + // This would make using try-with-resources less likely to fail + + // Cannot close the mdb_cursor if the txn is writable and not in a ready state + txn.checkReady(); + } } + LIB.mdb_cursor_close(ptrCursor); + releaseRefCount(); } - LIB.mdb_cursor_close(ptrCursor); - closed = true; } /** - * Return count of duplicates for current key. + * Return count of duplicates for the current key. * *

This call is only valid on databases that support sorted duplicate data items {@link * DbiFlags#MDB_DUPSORT}. * - * @return count of duplicates for current key + * @return count of duplicates for the current key */ public long count() { if (SHOULD_CHECK) { @@ -368,7 +407,6 @@ public void putMultiple(final T key, final T val, final int elements) { */ public void putMultiple(final T key, final T val, final int elements, final PutFlagSet flags) { if (SHOULD_CHECK) { - requireNonNull(txn); requireNonNull(key); requireNonNull(val); env.checkNotClosed(); @@ -397,19 +435,20 @@ public void putMultiple(final T key, final T val, final int elements, final PutF * may be associated with a new read-only transaction, and referencing the same database handle as * it was created with. This may be done whether the previous transaction is live or dead. * - * @param newTxn transaction handle + * @param newTxn The new transaction handle to associate with this cursor. It must be a read-only + * transaction and in a ready state, i.e. not committed/aborted/closed. */ public void renew(final Txn newTxn) { if (SHOULD_CHECK) { requireNonNull(newTxn); env.checkNotClosed(); checkNotClosed(); - this.txn.checkReadOnly(); // existing + txn.checkReadOnly(); // existing newTxn.checkReadOnly(); newTxn.checkReady(); } checkRc(LIB.mdb_cursor_renew(newTxn.pointer(), ptrCursor)); - this.txn = newTxn; + txn = newTxn; } /** @@ -518,11 +557,18 @@ public T val() { } private void checkNotClosed() { - if (closed) { + if (closed.get()) { throw new ClosedException(); } } + private void releaseRefCount() { + // May be null if the cursor was created with a read-only transaction + if (refCounterReleaser != null) { + refCounterReleaser.release(); + } + } + /** Cursor has already been closed. */ public static final class ClosedException extends LmdbException { diff --git a/src/main/java/org/lmdbjava/CursorIterable.java b/src/main/java/org/lmdbjava/CursorIterable.java index 65fc1023..ac53b16b 100644 --- a/src/main/java/org/lmdbjava/CursorIterable.java +++ b/src/main/java/org/lmdbjava/CursorIterable.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,6 +38,8 @@ * *

An instance will create and close its own cursor. * + *

Not thread safe. + * * @param buffer type */ public final class CursorIterable implements Iterable>, AutoCloseable { diff --git a/src/main/java/org/lmdbjava/Dbi.java b/src/main/java/org/lmdbjava/Dbi.java index d2afdf8f..9532af42 100644 --- a/src/main/java/org/lmdbjava/Dbi.java +++ b/src/main/java/org/lmdbjava/Dbi.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -408,6 +408,9 @@ public Cursor openCursor(final Txn txn) { /** * Starts a new read-write transaction and puts the key/data pair. * + *

NOTE: If this is called while this thread already has an open write transaction, it will + * block indefinitely. + * * @param key key to store in the database (not null) * @param val value to store in the database (not null) * @see #put(Txn, Object, Object, PutFlagSet) diff --git a/src/main/java/org/lmdbjava/Env.java b/src/main/java/org/lmdbjava/Env.java index 4bc6cca8..29196cce 100644 --- a/src/main/java/org/lmdbjava/Env.java +++ b/src/main/java/org/lmdbjava/Env.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,7 +45,26 @@ import org.lmdbjava.Library.MDB_stat; /** - * LMDB environment. + * An LMDB environment that includes one or more databases ({@link Dbi}s). The {@link Env} manages + * the transactions and databases. An {@link Env} can only have one concurrent write transaction but + * supports multiple concurrent read transactions. + * + *

WARNING: LMDBJava's and LMDB's performance comes from their low-level memory + * access, but this requires that you strictly adhere to the various contracts set out when using + * environments, databases, transactions, and cursors. Incorrect use of LMDBJava can lead to + * segmentation faults that can crash your application. + * + *

By default, LMDBJava performs some checks, for example, checking that the {@link Env} is not + * closed when opening a transaction. It is possible, however, for race conditions to occur if you + * close the {@link Env} after one of these checks has been performed and before the transaction is + * opened. Note, these checks can be disabled by setting the {@link #DISABLE_CHECKS_PROP} system + * property to {@code true}. This may be beneficial in performance-critical applications. + * + *

{@link Builder#setSafeClose()} can also be used to add additional checks that ensure the + * {@link Env} is not closed while transactions/cursors are in use. + * + *

It is the responsibility of the user to ensure that the {@link Env} is not closed while + * transactions or cursors are in use. * * @param buffer type */ @@ -67,7 +86,7 @@ public final class Env implements AutoCloseable { */ public static final boolean SHOULD_CHECK = !getBoolean(DISABLE_CHECKS_PROP); - private boolean closed; + private final RefCounter refCounter; private final int maxKeySize; private final boolean noSubDir; private final BufferProxy proxy; @@ -76,13 +95,21 @@ public final class Env implements AutoCloseable { private final Path path; private final EnvFlagSet envFlagSet; + /** True if this Env has been created on the basis of only ever being used by a single thread. */ + private final boolean isSingleThreaded; + + /** If true, close will be prevented if there are open txns/cursors. */ + private final boolean safeClose; + private Env( final BufferProxy proxy, final Pointer ptr, final boolean readOnly, final boolean noSubDir, final Path path, - final EnvFlagSet envFlagSet) { + final EnvFlagSet envFlagSet, + final boolean isSingleThreaded, + final boolean safeClose) { this.proxy = proxy; this.readOnly = readOnly; this.noSubDir = noSubDir; @@ -91,23 +118,40 @@ private Env( this.maxKeySize = LIB.mdb_env_get_maxkeysize(ptr); this.path = path; this.envFlagSet = envFlagSet; + this.isSingleThreaded = isSingleThreaded; + this.safeClose = safeClose; + this.refCounter = initRefCounter(isSingleThreaded); + } + + private RefCounter initRefCounter(boolean isSingleThreaded) { + final RefCounter refCounter; + if (safeClose) { + if (isSingleThreaded) { + refCounter = new SingleThreadedRefCounter(); + } else { + refCounter = new StripedRefCounter(); + } + } else { + refCounter = new NoOpRefCounter(); + } + return refCounter; } /** - * Create an {@link Env} using the {@link ByteBufferProxy#PROXY_OPTIMAL}. + * Create an {@link Env.Builder} using the {@link ByteBufferProxy#PROXY_OPTIMAL}. * - * @return the environment (never null) + * @return the builder for creating an environment. */ public static Builder create() { return new Builder<>(PROXY_OPTIMAL); } /** - * Create an {@link Env} using the passed {@link BufferProxy}. + * Create an {@link Env.Builder} using the passed {@link BufferProxy}. * * @param buffer type * @param proxy the proxy to use (required) - * @return the environment (never null) + * @return the builder for creating an environment. */ public static Builder create(final BufferProxy proxy) { return new Builder<>(proxy); @@ -124,20 +168,97 @@ public static Builder create(final BufferProxy proxy) { */ @Deprecated public static Env open(final File path, final int size, final EnvFlags... flags) { - return new Builder<>(PROXY_OPTIMAL).setMapSize(size, ByteUnit.MEBIBYTES).open(path, flags); + return new Builder<>(PROXY_OPTIMAL) + .setMapSize(size, ByteUnit.MEBIBYTES) + .setEnvFlags(flags) + .open(path, flags); } /** * Close the handle. * - *

Will silently return if already closed or never opened. + *

Will silently return if already closed. + * + *

Before and during this call, the caller MUST ensure that: + * + *

    + *
  • every {@link Txn} obtained from this environment has already been closed. + *
  • every {@link Cursor} associated with a read-write {@link Txn} obtained from this + * environment has already been closed. + *
  • no other thread is executing any operation on this environment or on a handle + * derived from it — including {@link #txnRead()} / {@link #txnWrite()} and reads such as + * {@code Dbi.get}. + *
+ * + *

Violating this contract is undefined behaviour that can crash the whole JVM + * ({@code SIGSEGV} on Linux/macOS, {@code EXCEPTION_ACCESS_VIOLATION 0xC0000005} on Windows); it + * does not raise a Java exception. The underlying {@code mdb_env_close} unmaps the + * memory map, so a transaction still being started or used on another thread then dereferences + * freed memory — typically observed as a native crash in {@code mdb_txn_renew0} / {@code + * mdb_txn_begin}. + * + *

If you must close an environment while reader threads may still be active, serialise the + * close against those readers in application code: e.g. a read/write lock where each reader holds + * the read lock for the entire duration of its transaction and {@code close()} holds the write + * lock, so the map is never unmapped while a read is in flight. + * + *

If safeClose has been enabled on the {@link Env}, then this method will throw a {@link + * EnvInUseException} if transactions or RW cursors are still active. + * + *

If safeClose has not been enabled then this method will perform the close regardless of + * whether it is in use or not with the implications detailed above. + * + * @throws EnvInUseException If safeClose has been set and a {@link Txn} or {@link Cursor} is + * still open on this {@link Env} */ @Override public void close() { - if (closed) { - return; - } - closed = true; + refCounter.close(this::doClose); + } + + /** + * Try to close the handle. + * + *

Will silently return if already closed. + * + *

Before and during this call, the caller MUST ensure that: + * + *

    + *
  • every {@link Txn} obtained from this environment has already been closed. + *
  • every {@link Cursor} associated with a read-write {@link Txn} obtained from this + * environment has already been closed. + *
  • no other thread is executing any operation on this environment or on a handle + * derived from it — including {@link #txnRead()} / {@link #txnWrite()} and reads such as + * {@code Dbi.get}. + *
+ * + *

Violating this contract is undefined behaviour that can crash the whole JVM + * ({@code SIGSEGV} on Linux/macOS, {@code EXCEPTION_ACCESS_VIOLATION 0xC0000005} on Windows); it + * does not raise a Java exception. The underlying {@code mdb_env_close} unmaps the + * memory map, so a transaction still being started or used on another thread then dereferences + * freed memory — typically observed as a native crash in {@code mdb_txn_renew0} / {@code + * mdb_txn_begin}. + * + *

If you must close an environment while reader threads may still be active, serialise the + * close against those readers in application code: e.g. a read/write lock where each reader holds + * the read lock for the entire duration of its transaction and {@code close()} holds the write + * lock, so the map is never unmapped while a read is in flight. + * + *

If safeClose has been enabled on the {@link Env}, then this method will return false if + * transactions or RW cursors are still active. + * + *

If safeClose has not been enabled then this method will perform the close regardless of + * whether it is in use or not with the implications detailed above, i.e. it has the same + * behaviour as {@link #close()} with safeClose disabled. + * + * @return {@code true} if the environment was closed or {@code false} if it was already closed or + * safeClose prevented its closure due to being in use. + */ + public boolean tryClose() { + return refCounter.tryClose(this::doClose); + } + + private void doClose() { LIB.mdb_env_close(ptr); } @@ -250,6 +371,7 @@ public List getDbiNames() { * *

This method must not be called from concurrent threads. * + * @param charset the charset to use when converting byte arrays to strings * @return a list of DBI names (never null) */ public List getDbiNames(final Charset charset) { @@ -297,9 +419,7 @@ public int getMaxKeySize() { * @return an immutable information object. */ public EnvInfo info() { - if (closed) { - throw new AlreadyClosedException(); - } + checkNotClosed(); final MDB_envinfo info = new MDB_envinfo(RUNTIME); checkRc(LIB.mdb_env_info(ptr, info)); @@ -325,7 +445,8 @@ public EnvInfo info() { * @return true if closed */ public boolean isClosed() { - return closed; + // TODO should this return true if state == CLOSING, or state != OPEN ? + return refCounter.isClosed(); } /** @@ -338,10 +459,24 @@ public boolean isReadOnly() { } /** - * Returns a builder for creating and opening a {@link Dbi} instance in this {@link Env}. + * Indicates if this environment is intended for use by a single thread for its entire life. * - *

The flag {@link DbiFlags#MDB_CREATE} needs to be set on the builder if you need to create a - * new database before opening it. + * @return True if single-threaded + */ + public boolean isSingleThreaded() { + return isSingleThreaded; + } + + boolean isSafeClose() { + return safeClose; + } + + /** + * Returns a builder for creating and opening a {@link Dbi} instance in this {@link Env}. This + * method is used for both opening an existing database or creating a new one. + * + *

The flag {@link DbiFlags#MDB_CREATE} needs to be set on the builder if the database does not + * already exist, and you need to create it before opening it. * * @return A new builder instance for creating/opening a {@link Dbi}. */ @@ -499,9 +634,7 @@ public Dbi openDbi( * @return an immutable statistics object. */ public Stat stat() { - if (closed) { - throw new AlreadyClosedException(); - } + checkNotClosed(); final MDB_stat stat = new MDB_stat(RUNTIME); checkRc(LIB.mdb_env_stat(ptr, stat)); return new Stat( @@ -520,9 +653,7 @@ public Stat stat() { * set the flushes will be omitted, and with MDB_MAPASYNC they will be asynchronous) */ public void sync(final boolean force) { - if (closed) { - throw new AlreadyClosedException(); - } + checkNotClosed(); final int f = force ? 1 : 0; checkRc(LIB.mdb_env_sync(ptr, f)); } @@ -533,6 +664,9 @@ public void sync(final boolean force) { * @return a transaction (never null) * @deprecated Instead use {@link Env#txn(Txn, TxnFlagSet)} *

Obtain a transaction with the requested parent and flags. + *

Must not race a concurrent {@link #close()} on another thread: the closed-check and the + * native transaction start are not atomic, so a close occurring between them can crash the + * JVM (see {@link #close()}). */ @Deprecated public Txn txn(final Txn parent, final TxnFlags... flags) { @@ -541,9 +675,18 @@ public Txn txn(final Txn parent, final TxnFlags... flags) { } /** - * Obtain a transaction with the requested parent and flags. + * Obtain a read-write transaction with the requested parent and flags. * - * @param parent parent transaction (may be null if no parent) + *

Must not race a concurrent {@link #close()} on another thread: the closed-check and the + * native transaction start are not atomic, so a close occurring between them can crash the JVM + * (see {@link #close()}). + * + *

When using a parent transaction, any committed changes will only be visible to the parent + * transaction and will only be fully committed to the {@link Dbi} if the root transaction is + * committed. Aborting this transaction will not roll back changes already made by the parent + * transaction. + * + * @param parent parent transaction (maybe null if no parent) * @return a transaction (never null) */ public Txn txn(final Txn parent) { @@ -554,11 +697,25 @@ public Txn txn(final Txn parent) { /** * Obtain a transaction with the requested parent and flags. * - * @param parent parent transaction (may be null if no parent) + *

If you want a read-write transaction, you can instead call {@link #txn(Txn)}. To obtain a + * read-only transaction, ensure {@link TxnFlags#MDB_RDONLY_TXN} is present in the {@link + * TxnFlagSet}. + * + *

Must not race a concurrent {@link #close()} on another thread: the closed-check and the + * native transaction start are not atomic, so a close occurring between them can crash the JVM + * (see {@link #close()}). + * + *

When using a parent transaction, any committed changes will only be visible to the parent + * transaction and will only be fully committed to the {@link Dbi} if the root transaction is + * committed. Aborting this transaction will not roll back changes already made by the parent + * transaction. + * + * @param parent parent transaction (maybe null if no parent) * @param flags applicable flags (e.g. for a reusable, read-only transaction). If the set of flags - * is used frequently it is recommended to hold a static instance of the {@link TxnFlagSet} + * is used frequently, it is recommended to hold a static instance of the {@link TxnFlagSet} * for re-use. * @return a transaction (never null) + * @throws Env.AlreadyClosedException if this environment has already been closed. */ public Txn txn(final Txn parent, final TxnFlagSet flags) { checkNotClosed(); @@ -568,7 +725,12 @@ public Txn txn(final Txn parent, final TxnFlagSet flags) { /** * Obtain a read-only transaction. * + *

Must not race a concurrent {@link #close()} on another thread: the closed-check and the + * native transaction start are not atomic, so a close occurring between them can crash the JVM + * (see {@link #close()}). + * * @return a read-only transaction + * @throws Env.AlreadyClosedException if this environment has already been closed. */ public Txn txnRead() { checkNotClosed(); @@ -578,7 +740,12 @@ public Txn txnRead() { /** * Obtain a read-write transaction. * + *

Must not race a concurrent {@link #close()} on another thread: the closed-check and the + * native transaction start are not atomic, so a close occurring between them can crash the JVM + * (see {@link #close()}). + * * @return a read-write transaction + * @throws Env.AlreadyClosedException if this environment has already been closed */ public Txn txnWrite() { checkNotClosed(); @@ -590,9 +757,7 @@ Pointer pointer() { } void checkNotClosed() { - if (closed) { - throw new AlreadyClosedException(); - } + refCounter.checkNotClosed(); } private void validateDirectoryEmpty(final Path path) { @@ -629,6 +794,18 @@ public int readerCheck() { return resultPtr.intValue(); } + /** + * Acquire a permit to use this {@link Env}. Holding the permit will prevent the {@link Env} from + * being closed before it is released. + * + * @return A {@link org.lmdbjava.RefCounter.RefCounterReleaser} for releasing the permit once the + * use of this {@link Env} is complete. + * @throws AlreadyClosedException if this Env is already closed. + */ + RefCounter.RefCounterReleaser acquire() { + return refCounter.acquire(); + } + /** For testing use. */ EnvFlagSet getEnvFlagSet() { return envFlagSet; @@ -638,7 +815,7 @@ EnvFlagSet getEnvFlagSet() { public String toString() { return "Env{" + "closed=" - + closed + + refCounter.isClosed() + ", maxKeySize=" + maxKeySize + ", noSubDir=" @@ -649,9 +826,30 @@ public String toString() { + path + ", envFlagSet=" + envFlagSet + + ", singleThreaded=" + + isSingleThreaded + '}'; } + /** Indicates that one or more transactions or cursors are in use on the {@link Env}. */ + public static final class EnvInUseException extends LmdbException { + + private static final long serialVersionUID = 1L; + + /** + * Creates a new instance. + * + * @param count The number of open transactions/cursors. + */ + public EnvInUseException(final long count) { + super( + "Environment has " + + count + + " open transactions/cursors so cannot be closed. " + + "Close them then retry."); + } + } + /** Object has already been closed and the operation is therefore prohibited. */ public static final class AlreadyClosedException extends LmdbException { @@ -688,9 +886,11 @@ public static final class Builder { private long mapSize = MAP_SIZE_DEFAULT; private int maxDbs = 1; private int maxReaders = MAX_READERS_DEFAULT; - private boolean opened; + private boolean opened = false; private final BufferProxy proxy; private int mode = POSIX_MODE_DEFAULT; + private boolean singleThreaded = false; + private boolean safeClose = false; private final AbstractFlagSet.Builder flagSetBuilder = EnvFlagSet.builder(); @@ -699,6 +899,12 @@ public static final class Builder { this.proxy = proxy; } + private void checkEnvNotOpened() { + if (opened) { + throw new AlreadyOpenException(); + } + } + /** * Opens the environment. * @@ -766,7 +972,7 @@ public Env open(final Path path) { final boolean readOnly = flags.isSet(MDB_RDONLY_ENV); final boolean noSubDir = flags.isSet(MDB_NOSUBDIR); checkRc(LIB.mdb_env_open(ptr, path.toAbsolutePath().toString(), flags.getMask(), mode)); - return new Env<>(proxy, ptr, readOnly, noSubDir, path, flags); + return new Env<>(proxy, ptr, readOnly, noSubDir, path, flags, singleThreaded, safeClose); } catch (final LmdbNativeException e) { LIB.mdb_env_close(ptr); throw e; @@ -780,9 +986,7 @@ public Env open(final Path path) { * @return the builder */ public Builder setMapSize(final long mapSize) { - if (opened) { - throw new AlreadyOpenException(); - } + checkEnvNotOpened(); if (mapSize < 0) { throw new IllegalArgumentException("Negative value; overflow?"); } @@ -812,9 +1016,7 @@ public Builder setMapSize(final long mapSize, final ByteUnit byteUnit) { * @return the builder */ public Builder setMaxDbs(final int dbs) { - if (opened) { - throw new AlreadyOpenException(); - } + checkEnvNotOpened(); this.maxDbs = dbs; return this; } @@ -826,9 +1028,7 @@ public Builder setMaxDbs(final int dbs) { * @return the builder */ public Builder setMaxReaders(final int readers) { - if (opened) { - throw new AlreadyOpenException(); - } + checkEnvNotOpened(); this.maxReaders = readers; return this; } @@ -841,9 +1041,7 @@ public Builder setMaxReaders(final int readers) { * @return the builder */ public Builder setFilePermissions(final int mode) { - if (opened) { - throw new AlreadyOpenException(); - } + checkEnvNotOpened(); this.mode = mode; return this; } @@ -856,6 +1054,7 @@ public Builder setFilePermissions(final int mode) { * @return this builder instance. */ public Builder setEnvFlags(final Collection envFlags) { + checkEnvNotOpened(); flagSetBuilder.clear(); if (envFlags != null) { envFlags.stream().filter(Objects::nonNull).forEach(flagSetBuilder::addFlag); @@ -871,6 +1070,7 @@ public Builder setEnvFlags(final Collection envFlags) { * @return this builder instance. */ public Builder setEnvFlags(final EnvFlags... envFlags) { + checkEnvNotOpened(); flagSetBuilder.clear(); if (envFlags != null) { Arrays.stream(envFlags).filter(Objects::nonNull).forEach(this.flagSetBuilder::addFlag); @@ -886,6 +1086,7 @@ public Builder setEnvFlags(final EnvFlags... envFlags) { * @return this builder instance. */ public Builder setEnvFlags(final EnvFlagSet envFlagSet) { + checkEnvNotOpened(); flagSetBuilder.clear(); if (envFlagSet != null) { this.flagSetBuilder.setFlags(envFlagSet.getFlags()); @@ -900,6 +1101,7 @@ public Builder setEnvFlags(final EnvFlagSet envFlagSet) { * @return this builder instance. */ public Builder addEnvFlag(final EnvFlags envFlag) { + checkEnvNotOpened(); this.flagSetBuilder.addFlag(envFlag); return this; } @@ -911,6 +1113,7 @@ public Builder addEnvFlag(final EnvFlags envFlag) { * @return this builder instance. */ public Builder addEnvFlags(final EnvFlagSet envFlagSet) { + checkEnvNotOpened(); if (envFlagSet != null) { flagSetBuilder.addFlags(envFlagSet.getFlags()); } @@ -925,11 +1128,77 @@ public Builder addEnvFlags(final EnvFlagSet envFlagSet) { * @return this builder instance. */ public Builder addEnvFlags(final Collection envFlags) { + checkEnvNotOpened(); if (envFlags != null) { flagSetBuilder.addFlags(envFlags); } return this; } + + /** + * If set, the caller is asserting that the Env will only be used by a single thread throughout + * its entire life. This allows the {@link Env} to make minor optimisations that are not + * thread-safe, e.g. using primitives rather than thread-safe objects. By default, an Env is + * assumed to be used by multiple threads. + * + * @return this builder instance. + */ + public Builder setSingleThreaded() { + checkEnvNotOpened(); + singleThreaded = true; + return this; + } + + /** + * If set to true, the caller is asserting that the Env will only be used by a single thread + * throughout its entire life. This allows the {@link Env} to make minor optimisations that are + * not thread-safe, e.g. using primitives rather than thread-safe objects. By default, an Env is + * assumed to be used by multiple threads. + * + * @param singleThreaded Set to true if the Env will only ever be used by a single thread. + * @return this builder instance. + */ + public Builder setSingleThreaded(final boolean singleThreaded) { + checkEnvNotOpened(); + this.singleThreaded = singleThreaded; + return this; + } + + /** + * Enables the opt-in "safe close" for the resulting {@link Env}. + * + *

When enabled, the environment tracks its live transactions and read-write cursors so that + * closure of the {@link Env} is prevented if transactions or cursors are active. This adds a + * small amount of bookkeeping on transaction start/close; it is disabled by + * default so applications that already manage their own threading (the common + * low-latency case) pay nothing. When enabled, {@link Env#close()} will throw a {@link + * EnvInUseException} if transactions or cursors are active. + * + * @return the builder + */ + public Builder setSafeClose() { + checkEnvNotOpened(); + return setSafeClose(true); + } + + /** + * Enables the opt-in "safe close" for the resulting {@link Env}. + * + *

When enabled, the environment tracks its live transactions and read-write cursors so that + * closure of the {@link Env} is prevented if transactions or cursors are active. This adds a + * small amount of bookkeeping on transaction start/close; it is disabled by + * default so applications that already manage their own threading (the common + * low-latency case) pay nothing. When enabled, {@link Env#close()} will throw a {@link + * EnvInUseException} if transactions or cursors are active. + * + * @param safeClose true to enable cursor/transaction tracking. + * @return the builder + */ + public Builder setSafeClose(final boolean safeClose) { + checkEnvNotOpened(); + this.safeClose = safeClose; + return this; + } } /** File is not a valid LMDB file. */ diff --git a/src/main/java/org/lmdbjava/NoOpRefCounter.java b/src/main/java/org/lmdbjava/NoOpRefCounter.java new file mode 100644 index 00000000..e82521c3 --- /dev/null +++ b/src/main/java/org/lmdbjava/NoOpRefCounter.java @@ -0,0 +1,74 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Preforms no reference counting at all, but will throw an Env.AlreadyClosedException if the {@link + * Env} is closed when {@link NoOpRefCounter#acquire()} is called. + */ +public class NoOpRefCounter implements RefCounter { + + /** A {@link RefCounterReleaser} that does nothing. */ + private static final RefCounterReleaser NO_OP_RELEASER = + () -> { + // No-op + }; + + private final AtomicBoolean isClosed = new AtomicBoolean(false); + + @Override + public RefCounterReleaser acquire() { + return NO_OP_RELEASER; + } + + @Override + public void use(final Runnable runnable) { + if (runnable != null) { + runnable.run(); + } + } + + @Override + public void close(final Runnable onClose) { + if (isClosed.compareAndSet(false, true)) { + // Close with no checks + onClose.run(); + } + } + + @Override + public boolean tryClose(Runnable onClose) { + if (isClosed.compareAndSet(false, true)) { + // Close with no checks + onClose.run(); + return true; + } else { + return false; + } + } + + @Override + public boolean isClosed() { + return isClosed.get(); + } + + @Override + public long getCount() { + return 0; + } +} diff --git a/src/main/java/org/lmdbjava/RefCounter.java b/src/main/java/org/lmdbjava/RefCounter.java new file mode 100644 index 00000000..1118451f --- /dev/null +++ b/src/main/java/org/lmdbjava/RefCounter.java @@ -0,0 +1,94 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +/** + * Used to prevent the closure of a resource while other threads are actively using that resource. + * Achieves this via reference counting. + */ +interface RefCounter { + + /** + * Call this before using the {@link RefCounter} controlled object. + * + * @return A {@link RefCounterReleaser} to release once the work is complete + */ + RefCounterReleaser acquire(); + + /** + * Calls {@link RefCounter#acquire()}, runs runnable, then calls {@link + * RefCounterReleaser#release()}. + * + *

If runnable is null, this is a no-op. + */ + default void use(final Runnable runnable) { + if (runnable != null) { + final RefCounterReleaser releaser = acquire(); + try { + runnable.run(); + } finally { + releaser.release(); + } + } + } + + /** + * If the reference count is zero, onClose will be called. This {@link RefCounter} will be marked + * as closed so all future calls to acquire will throw a {@link + * org.lmdbjava.Env.AlreadyClosedException}. If the count is non-zero, {@link + * org.lmdbjava.Env.EnvInUseException} will be thrown. If already closed, this is a no-op. + * + * @throws org.lmdbjava.Env.EnvInUseException If the {@link Env} has open transactions/cursors. + */ + void close(final Runnable onClose); + + /** + * If the reference count is zero, onClose will be called and true returned. This {@link + * RefCounter} will be marked as closed so all future calls to acquire will throw a {@link + * org.lmdbjava.Env.AlreadyClosedException}. If the count is non-zero it is a no-op and false is + * returned. If already closed, this is a no-op and false is returned. + * + * @return True if onClose was called. + */ + boolean tryClose(final Runnable onClose); + + /** + * @return True if {@link RefCounter} has been closed. + */ + boolean isClosed(); + + /** If it is in a CLOSED state, throw a {@link org.lmdbjava.Env.AlreadyClosedException} */ + default void checkNotClosed() { + if (isClosed()) { + throw new Env.AlreadyClosedException(); + } + } + + /** + * @return The current count of items in use. It will return 0 if already closed. + */ + long getCount(); + + @FunctionalInterface + interface RefCounterReleaser { + + /** + * Call this after using the {@link RefCounter} controlled object. Subsequent calls to this + * method are a no-op. + */ + void release(); + } +} diff --git a/src/main/java/org/lmdbjava/SimpleRefCounter.java b/src/main/java/org/lmdbjava/SimpleRefCounter.java new file mode 100644 index 00000000..98c9be0f --- /dev/null +++ b/src/main/java/org/lmdbjava/SimpleRefCounter.java @@ -0,0 +1,90 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An implementation of {@link RefCounter} that uses an {@link AtomicInteger} to track the number of + * references to a resource. + */ +class SimpleRefCounter implements RefCounter { + private static final int CLOSED_VALUE = Integer.MIN_VALUE; + private final AtomicInteger counter = new AtomicInteger(0); + + @Override + public boolean isClosed() { + return counter.get() == CLOSED_VALUE; + } + + @Override + public RefCounterReleaser acquire() { + final int newVal = + counter.updateAndGet(currVal -> currVal == CLOSED_VALUE ? currVal : currVal + 1); + if (newVal == CLOSED_VALUE) { + throw new Env.AlreadyClosedException(); + } + + final AtomicBoolean hasReleased = new AtomicBoolean(false); + return () -> { + // Prevent duplicate release calls + if (hasReleased.compareAndSet(false, true)) { + release(); + } + }; + } + + @Override + public void close(final Runnable onClose) { + Objects.requireNonNull(onClose); + if (counter.get() != CLOSED_VALUE) { + // Set to CLOSED_VALUE to indicate closure, if the count is 0 + if (counter.compareAndSet(0, CLOSED_VALUE)) { + onClose.run(); + } else { + throw new Env.EnvInUseException(getCount()); + } + } + } + + @Override + public boolean tryClose(Runnable onClose) { + Objects.requireNonNull(onClose); + if (counter.get() != CLOSED_VALUE) { + // Set to CLOSED_VALUE to indicate closure, if the count is 0 + if (counter.compareAndSet(0, CLOSED_VALUE)) { + onClose.run(); + return true; + } + } + return false; + } + + private void release() { + final int newVal = + counter.updateAndGet(currVal -> currVal == CLOSED_VALUE ? currVal : currVal - 1); + if (newVal == CLOSED_VALUE) { + throw new Env.AlreadyClosedException(); + } + } + + @Override + public long getCount() { + return Math.max(0, counter.get()); + } +} diff --git a/src/main/java/org/lmdbjava/SingleThreadedRefCounter.java b/src/main/java/org/lmdbjava/SingleThreadedRefCounter.java new file mode 100644 index 00000000..4e293f3f --- /dev/null +++ b/src/main/java/org/lmdbjava/SingleThreadedRefCounter.java @@ -0,0 +1,111 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import java.util.Objects; + +/** A {@link RefCounter} intented for use only in single-threaded environments. */ +public class SingleThreadedRefCounter implements RefCounter { + + private int refCount; + private boolean isClosed = false; + + public SingleThreadedRefCounter() {} + + @Override + public RefCounterReleaser acquire() { + if (isClosed) { + throw new Env.AlreadyClosedException(); + } + refCount++; + return new SingleThreadedReleaser(this); + } + + private void release() { + if (refCount == 0) { + throw new IllegalStateException("Attempt to release with a refCount of zero"); + } + refCount--; + } + + @Override + public void use(Runnable runnable) { + if (runnable != null) { + final RefCounterReleaser releaser = acquire(); + try { + runnable.run(); + } finally { + releaser.release(); + } + } + } + + @Override + public void close(final Runnable onClose) { + Objects.requireNonNull(onClose); + if (!isClosed) { + final long count = getCount(); + if (count == 0) { + isClosed = true; + onClose.run(); + } else { + throw new Env.EnvInUseException(count); + } + } + } + + @Override + public boolean tryClose(Runnable onClose) { + Objects.requireNonNull(onClose); + if (!isClosed) { + final long count = getCount(); + if (count == 0) { + isClosed = true; + onClose.run(); + return true; + } + } + return false; + } + + @Override + public boolean isClosed() { + return isClosed; + } + + @Override + public long getCount() { + return refCount; + } + + private static class SingleThreadedReleaser implements RefCounterReleaser { + + private final SingleThreadedRefCounter singleThreadedRefCounter; + private boolean released = false; + + private SingleThreadedReleaser(final SingleThreadedRefCounter singleThreadedRefCounter) { + this.singleThreadedRefCounter = singleThreadedRefCounter; + } + + @Override + public void release() { + if (!released) { + released = true; + singleThreadedRefCounter.release(); + } + } + } +} diff --git a/src/main/java/org/lmdbjava/StripedRefCounter.java b/src/main/java/org/lmdbjava/StripedRefCounter.java new file mode 100644 index 00000000..a052f9e5 --- /dev/null +++ b/src/main/java/org/lmdbjava/StripedRefCounter.java @@ -0,0 +1,414 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An implementation of {@link RefCounter} that uses an array of {@link AtomicInteger}s to track the + * number of references to a resource. Offers better concurrency performance than {@link + * SimpleRefCounter} which used a single {@link AtomicInteger}, at the cost of more memory due to + * the additional {@link AtomicInteger}s. + * + *

Each thread will use the {@link AtomicInteger} at an array offset determined by a hash of the + * thread's id. + * + *

The number of stripes configurable but immutable once set. + */ +class StripedRefCounter implements RefCounter { + private static final int PROCESSOR_COUNT = Runtime.getRuntime().availableProcessors(); + + /** + * Counter value used to indicate a count of zero while a sum of all counters is being performed. + */ + private static final int MAGIC_ZERO_VALUE = Integer.MIN_VALUE; + + /** Counter value used to indicate that this RefCounter has been closed. */ + private static final int MAGIC_CLOSED_VALUE = Integer.MAX_VALUE; + + /** The maximum possible count value on one stripe. */ + private static final int MAX_COUNTER_VALUE = Integer.MAX_VALUE - 1; + + private static final int DEFAULT_STRIPES = 64; + + /** Maximum number of stripes. */ + private static final int MAX_STRIPES = 256; + + private final Stripe[] counters; + private final AtomicBoolean isClosed = new AtomicBoolean(false); + + /** + * Bit mask for fast stripe index calculation. Equal to (stripeCount - 1). Used with bitwise AND + * for O(1) hashing with no modulo operation. + */ + private final int stripeMask; + + StripedRefCounter() { + this(getDefaultStripeCount()); + } + + StripedRefCounter(final int stripeCount) { + final int effectiveStripeCount = lowestPowerOfTwoGreaterThanOrEqualTo(stripeCount); + validateStripeCount(effectiveStripeCount); + this.stripeMask = effectiveStripeCount - 1; + this.counters = new Stripe[effectiveStripeCount]; + for (int i = 0; i < effectiveStripeCount; i++) { + counters[i] = new Stripe(this); + } + } + + public int getStripeCount() { + return counters.length; + } + + @Override + public boolean isClosed() { + return isClosed.get(); + } + + private AtomicInteger getCounterForThisThread() { + return counters[getStripeIdx()].counter; + } + + private Stripe getStripeForThisThread() { + return counters[getStripeIdx()]; + } + + @Override + public RefCounterReleaser acquire() { + final Stripe stripe = getStripeForThisThread(); + final AtomicInteger counter = stripe.counter; + if (!addToCounter(counter, Delta.PLUS_ONE)) { + // Counting is in progress, so we need to get a lock which will likely block + // until the count is complete + synchronized (this) { + if (!addToCounter(counter, Delta.PLUS_ONE)) { + throw new IllegalStateException("Count should not be in progress while we hold the lock"); + } + } + } + return stripe.createReleaser(); + } + + private static int getDefaultStripeCount() { + return Math.min( + MAX_STRIPES, + Math.max(lowestPowerOfTwoGreaterThanOrEqualTo(PROCESSOR_COUNT * 2), DEFAULT_STRIPES)); + } + + /** + * Returns the lowest power of two that is greater than or equal to {@code value}. + * + * @param value input value, must be positive + * @return lowest power of two >= value + * @throws IllegalArgumentException if {@code value <= 0} or the result would overflow an int + */ + static int lowestPowerOfTwoGreaterThanOrEqualTo(final int value) { + if (value <= 0) { + throw new IllegalArgumentException("Value must be positive, got: " + value); + } + if (value > (1 << 30)) { + throw new IllegalArgumentException( + "Value is too large to round up to a positive int power of two, got: " + value); + } + return value == 1 ? 1 : Integer.highestOneBit(value - 1) << 1; + } + + private void release(final AtomicInteger counter) { + if (!addToCounter(counter, Delta.MINUS_ONE)) { + synchronized (this) { + if (!addToCounter(counter, Delta.MINUS_ONE)) { + throw new IllegalStateException("Count should not be in progress while we hold the lock"); + } + } + } + } + + @Override + public void close(final Runnable onClose) { + final Long count = doClose(onClose); + if (count != null && count > 0) { + throw new Env.EnvInUseException(count); + } + } + + @Override + public boolean tryClose(Runnable onClose) { + final Long count = doClose(onClose); + return count != null && count == 0; + } + + /** + * @return A non-zero count to indicate the resource is in use. A zero count to indicate the + * onClose was called successfully. A null count to indicate the resource was already in a + * closed state. + */ + private Long doClose(final Runnable onClose) { + Objects.requireNonNull(onClose); + + // close is idempotent so silently drop out + if (isClosed.get()) { + return null; + } + + synchronized (this) { + if (isClosed.get()) { + return null; + } + + // Once we have marked all counters as count-in-progress, any threads trying to mutate the + // counters + // will fail, then re-attempt under lock, so will have to wait for us to complete the count. + // Marking all the counters is a non-atomic operation, so another thread may increment a + // counter + // while we are in the middle of marking them, however, once all are marked, threads will be + // blocked + // from decrementing until we have called markCountersAsNoCountInProgress(), thus we will get + // a non-zero + // count and throw an EnvInUseException. + + markCountersAsCountInProgress(); // 0=>MAGIC_ZERO_VALUE else i=>i*-1 + + // At this point, no other thread can mutate the counters, so we are safe to use a sum of all + // the counters. + try { + final long totalCount = sumCounters(); + if (totalCount == 0) { + // No permits on loan so safe to close. + onClose.run(); + // Only mark as closed if the runnable succeeds. + isClosed.set(true); + // Mark all counters as closed to prevent any future acquire() calls + for (final Stripe stripe : counters) { + stripe.counter.set(MAGIC_CLOSED_VALUE); + } + } + return totalCount; + } finally { + if (!isClosed.get()) { + // Return all counters to their original positive values so + // acquire/release can resume as normal + markCountersAsNoCountInProgress(); // MAGIC_ZERO_VALUE=>0 else i=>i*-1 + } + } + } + } + + /** + * MUST be called after {@link StripedRefCounter#markCountersAsCountInProgress()} has been called + * and under lock. Once complete, {@link StripedRefCounter#markCountersAsNoCountInProgress()} must + * be called. + */ + private long sumCounters() { + long totalCount = 0; + for (Stripe stripe : counters) { + int count = stripe.counter.get(); + if (count == MAGIC_CLOSED_VALUE) { // Integer.MAX_VALUE + throw new Env.AlreadyClosedException(); + } else if (count != MAGIC_ZERO_VALUE) { // Integer.MIN_VALUE + // count should be negative at this point + if (count > 0) { + throw new IllegalStateException("Count should be negative at this point, got: " + count); + } + totalCount += count; + } + } + // The individual counts were all negative, so use the abs value + totalCount = Math.abs(totalCount); + return totalCount; + } + + @Override + public long getCount() { + if (isClosed()) { + return 0; + } + synchronized (this) { + if (isClosed()) { + return 0; + } + // This will stop any other thread from incrementing/decrementing the counter + markCountersAsCountInProgress(); + try { + return sumCounters(); + } finally { + markCountersAsNoCountInProgress(); + } + } + } + + /** + * @return False if a count is in progress, else true + * @throws Env.AlreadyClosedException If this {@link RefCounter} has already been successfully + * closed. + */ + private boolean addToCounter(final AtomicInteger counter, final Delta delta) { + // Use a while loop with get() and compareAndSet(), rather than throwing exceptions inside + // updateAndGet(). + while (true) { + final int currVal = counter.get(); + + if (currVal == MAGIC_CLOSED_VALUE) { + // Once MAGIC_CLOSED_VALUE is set, it is never mutated again. + throw new Env.AlreadyClosedException(); + } else if (currVal < 0) { + // A count is in progress, so we can drop out and try again under lock + return false; + } else if (currVal == MAX_COUNTER_VALUE && delta == Delta.PLUS_ONE) { + // This implies we have a LOT of txns/cursors open, should never happen + throw new IllegalStateException("Reference count overflow"); + } else if (currVal == 0 && delta == Delta.MINUS_ONE) { + throw new IllegalStateException("Reference count underflow"); + } + + final int newVal = currVal + delta.deltaValue; + if (counter.compareAndSet(currVal, newVal)) { + return true; + } + } + } + + /** Must be called while holding the lock on this object. */ + private void markCountersAsNoCountInProgress() { + for (Stripe stripe : counters) { + // Multiply value by -1 so we can indicate to other threads that a count is in progress + // while maintaining the count. Have to use a special replacement value for zero. + stripe.counter.updateAndGet( + currVal -> { + if (currVal == MAGIC_ZERO_VALUE) { + return 0; + } else if (currVal == MAGIC_CLOSED_VALUE) { + // If this method is used correctly under lock, we should never see this value, but + // preserve the + // closed state just in case + return MAGIC_CLOSED_VALUE; + } else { + return Math.abs(currVal); + } + }); + } + } + + /** Must be called while holding the lock on this object. */ + private void markCountersAsCountInProgress() { + // It is possible that another thread will call acquire() while we are mid-loop. + // If that thread uses a counter that has not yet been marked as count-in-progress, they will + // succeed with incrementing the counter. + // We will then get a sum that includes the increment from their acquire() call. + // They will be blocked from calling release() until markCountersAsNoCountInProgress() has + // been called by us. + for (final Stripe stripe : counters) { + stripe.counter.updateAndGet( + currVal -> { + if (currVal == 0) { + // Use a magic value to mark this zero-value counter as having a count in progress + return MAGIC_ZERO_VALUE; + } else if (currVal == MAGIC_CLOSED_VALUE) { + // If this method is used correctly under lock, we should never see this value, but + // preserve the + // closed state just in case + return MAGIC_CLOSED_VALUE; + } else { + // Make the value negative to indicate a count in progress + return Math.abs(currVal) * -1; + } + }); + } + } + + private void validateStripeCount(final int stripeCount) { + if (stripeCount <= 0) { + throw new IllegalArgumentException("Stripe count must be positive, got: " + stripeCount); + } + if (stripeCount > MAX_STRIPES) { + throw new IllegalArgumentException( + "Stripe count exceeds maximum. Got: " + stripeCount + ", max: " + MAX_STRIPES); + } + } + + /** + * Computes the stripe index for the current thread using Stafford variant 13 mixing. + * + *

This method applies a high-quality 64-bit hash function (MurmurHash3 finalizer) to the + * thread ID before masking to the stripe count. This provides: + * + *

    + *
  • Excellent distribution for sequential thread IDs + *
  • Same thread always maps to same stripe (deterministic) + *
  • Strong avalanche properties (input bit changes affect all output bits) + *
  • O(1) performance + *
+ * + *

The Stafford13 mixing function is used internally by {@link java.util.SplittableRandom} for + * seed initialization. See: Better Bit + * Mixing + * + * @return stripe index from 0 to stripeCount - 1 (inclusive) + */ + private int getStripeIdx() { + // TODO In >= Java19, getId() is deprecated, so change to .threadId() + long threadId = Thread.currentThread().getId(); + // Stafford13 for sequential inputs + threadId = (threadId ^ (threadId >>> 30)) * 0xbf58476d1ce4e5b9L; + threadId = (threadId ^ (threadId >>> 27)) * 0x94d049bb133111ebL; + return (int) ((threadId ^ (threadId >>> 31)) & stripeMask); + } + + private enum CloseOutcome { + /** Successfully closed. */ + CLOSED, + /** The resource is in use. */ + IN_USE, + /** Already in a closed state. */ + ALREADY_CLOSED, + ; + } + + private enum Delta { + PLUS_ONE(1), + MINUS_ONE(-1), + ; + + private final int deltaValue; + + Delta(int deltaValue) { + this.deltaValue = deltaValue; + } + } + + private static class Stripe { + private final StripedRefCounter stripedRefCounter; + private final AtomicInteger counter; + + Stripe(StripedRefCounter stripedRefCounter) { + this.stripedRefCounter = stripedRefCounter; + this.counter = new AtomicInteger(); + } + + RefCounterReleaser createReleaser() { + final AtomicBoolean hasReleased = new AtomicBoolean(false); + return () -> { + // Prevent duplicate release calls + if (hasReleased.compareAndSet(false, true)) { + stripedRefCounter.release(counter); + } + }; + } + } +} diff --git a/src/main/java/org/lmdbjava/SynchronisedRefCounter.java b/src/main/java/org/lmdbjava/SynchronisedRefCounter.java new file mode 100644 index 00000000..85158606 --- /dev/null +++ b/src/main/java/org/lmdbjava/SynchronisedRefCounter.java @@ -0,0 +1,101 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * An implementation of {@link RefCounter} that uses synchronisation to track the number of + * references to a resource. + */ +class SynchronisedRefCounter implements RefCounter { + private boolean isClosed = false; + private int counter = 0; + + @Override + public boolean isClosed() { + synchronized (this) { + return isClosed; + } + } + + @Override + public RefCounterReleaser acquire() { + synchronized (this) { + if (isClosed) { + throw new Env.AlreadyClosedException(); + } + counter++; + } + final AtomicBoolean hasReleased = new AtomicBoolean(false); + return () -> { + // Prevent duplicate release calls + if (hasReleased.compareAndSet(false, true)) { + release(); + } + }; + } + + @Override + public void close(final Runnable onClose) { + Objects.requireNonNull(onClose); + synchronized (this) { + if (!isClosed) { + if (counter != 0) { + throw new Env.EnvInUseException(getCount()); + } else { + isClosed = true; + onClose.run(); + } + } + } + } + + @Override + public boolean tryClose(Runnable onClose) { + Objects.requireNonNull(onClose); + synchronized (this) { + if (!isClosed) { + if (counter == 0) { + isClosed = true; + onClose.run(); + return true; + } + } + } + return false; + } + + private void release() { + synchronized (this) { + if (isClosed) { + throw new Env.AlreadyClosedException(); + } + if (counter == 0) { + throw new IllegalStateException("Attempt to decrement counter below zero"); + } + counter--; + } + } + + @Override + public long getCount() { + synchronized (this) { + return counter; + } + } +} diff --git a/src/main/java/org/lmdbjava/TargetName.java b/src/main/java/org/lmdbjava/TargetName.java index 49a65dea..7987c0c4 100644 --- a/src/main/java/org/lmdbjava/TargetName.java +++ b/src/main/java/org/lmdbjava/TargetName.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/org/lmdbjava/Txn.java b/src/main/java/org/lmdbjava/Txn.java index 7e9aacf9..df6259b2 100644 --- a/src/main/java/org/lmdbjava/Txn.java +++ b/src/main/java/org/lmdbjava/Txn.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,7 +31,21 @@ import jnr.ffi.Pointer; /** - * LMDB transaction. + * An LMDB ACID transaction. + * + *

A transaction belongs to an {@link Env} and must be closed before the {@link Env} is closed. + * Only one concurrent write transaction is supported. Attempts to open another write transaction + * will block until the open write transaction is closed. + * + *

{@link Txn#commit()} must be called to commit any changes made within the transaction. + * + *

Uncommitted changes can be rolled back by either calling {@link Txn#close()} or calling {@link + * Txn#abort()}. + * + *

Closing a transaction without first calling {@link Txn#commit()} will perform an implicit + * rollback of any uncommitted changes made within the transaction. + * + *

Transactions can be nested * * @param buffer type */ @@ -43,9 +57,11 @@ public final class Txn implements AutoCloseable { private final Pointer ptr; private final boolean readOnly; private final Env env; - private State state; + private final RefCounter.RefCounterReleaser refCounterReleaser; + private volatile State state; Txn(final Env env, final Txn parent, final BufferProxy proxy, final TxnFlagSet flags) { + if (SHOULD_CHECK) { Objects.requireNonNull(flags); } @@ -60,15 +76,28 @@ public final class Txn implements AutoCloseable { if (parent != null && parent.isReadOnly() != this.readOnly) { throw new IncompatibleParent(); } - final Pointer txnPtr = allocateDirect(RUNTIME, ADDRESS); - final Pointer txnParentPtr = parent == null ? null : parent.ptr; - checkRc(LIB.mdb_txn_begin(env.pointer(), txnParentPtr, flags.getMask(), txnPtr)); - ptr = txnPtr.getPointer(0); - state = READY; + this.refCounterReleaser = env.acquire(); + try { + final Pointer txnPtr = allocateDirect(RUNTIME, ADDRESS); + final Pointer txnParentPtr = parent == null ? null : parent.ptr; + checkRc(LIB.mdb_txn_begin(env.pointer(), txnParentPtr, flags.getMask(), txnPtr)); + ptr = txnPtr.getPointer(0); + + state = READY; + } catch (final Exception e) { + this.refCounterReleaser.release(); + throw e; + } } - /** Aborts this transaction. */ + /** + * Aborts this transaction. + * + *

If this is a read-write transaction, and you have any open {@link Cursor}s against this + * transaction, they MUST be closed first, else you will not be able to close the + * cursor after this transaction has been committed. + */ public void abort() { if (SHOULD_CHECK) { env.checkNotClosed(); @@ -76,13 +105,24 @@ public void abort() { checkReady(); state = DONE; LIB.mdb_txn_abort(ptr); + + // No call to refCounterReleaser.release() here because the keyVal is still open + // and the txn can still be reset. } /** - * Closes this transaction by aborting if not already committed. + * Closes this transaction. Any uncommitted work will be aborted first. + * + *

If any {@link Cursor}s have been opened on this transaction, they MUST be + * closed first, else you will not be able to close the cursor after its transaction has been + * closed. * *

Closing the transaction will invoke {@link BufferProxy#deallocate(java.lang.Object)} for * each read-only buffer (ie the key and value). + * + *

If this is a read-write transaction, and you have any open {@link Cursor}s against this + * transaction, they MUST be closed first, else you will not be able to close the + * cursor after this transaction has been closed. */ @Override public void close() { @@ -97,9 +137,20 @@ public void close() { } keyVal.close(); state = RELEASED; + + refCounterReleaser.release(); } - /** Commits this transaction. */ + /** + * Commits this transaction. + * + *

If you have an open cursor using this transaction, you must close the cursor before + * committing. + * + *

If this is a read-write transaction, and you have any open {@link Cursor}s against this + * transaction, they MUST be closed first, else you will not be able to close the + * cursor after this transaction has been committed. + */ public void commit() { if (SHOULD_CHECK) { env.checkNotClosed(); @@ -124,7 +175,7 @@ public long getId() { /** * Obtains this transaction's parent. * - * @return the parent transaction (may be null) + * @return the parent transaction (if present, i.e. may be null) */ public Txn getParent() { return parent; @@ -139,6 +190,15 @@ public boolean isReadOnly() { return readOnly; } + /** + * Whether this transaction is writable (i.e. not read-only). + * + * @return if writable + */ + public boolean isWritable() { + return !readOnly; + } + /** * Fetch the buffer which holds a read-only view of the LMDI allocated memory. Any use of this * buffer must comply with the standard LMDB C "mdb_get" contract (ie do not modify, do not @@ -167,6 +227,8 @@ public void renew() { /** * Aborts this read-only transaction and resets the transaction handle, so it can be reused upon * calling {@link #renew()}. + * + *

Not applicable to write transactions. */ public void reset() { if (SHOULD_CHECK) { @@ -204,6 +266,10 @@ void checkReady() { } } + boolean isReady() { + return state == READY; + } + void checkWritesAllowed() { if (readOnly) { throw new ReadWriteRequiredException(); @@ -282,7 +348,10 @@ public static final class NotReadyException extends LmdbException { /** Creates a new instance. */ public NotReadyException() { - super("Transaction is not in ready state"); + super( + "Transaction is not in ready state, i.e. it has been closed/committed/aborted/reset. " + + "You may see this if have you tried to close a cursor after committing the transaction, " + + "or if you have tried to use a cursor after closing its transaction."); } } diff --git a/src/test/java/org/lmdbjava/AbstractFlagSetTest.java b/src/test/java/org/lmdbjava/AbstractFlagSetTest.java index a886d9e1..217217e7 100644 --- a/src/test/java/org/lmdbjava/AbstractFlagSetTest.java +++ b/src/test/java/org/lmdbjava/AbstractFlagSetTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -67,6 +67,15 @@ void testSingleFlagSet() { final List allFlags = getAllFlags(); for (T flag : allFlags) { final F flagSet = getBuilder().addFlag(flag).build(); + + // Compare as a Set + assertThat(flagSet.getFlags()).isEqualTo(flag.getFlags()); + // Compare as a FlagSet because a single flag enum implements FlagSet + assertThat(flagSet.equals(flag)).isTrue(); + assertThat(flag.equals(flagSet)).isTrue(); + assertThat(FlagSet.equals(flagSet, flag)).isTrue(); + assertThat(flagSet.hashCode()).isEqualTo(flag.hashCode()); + assertThat(flagSet.getMask()).isEqualTo(flag.getMask()); assertThat(flagSet.getMask()).isEqualTo(MaskedFlag.mask(flag)); assertThat(flagSet.getFlags()).containsExactly(flag); @@ -88,6 +97,12 @@ void testSingleFlagSet() { assertThat(flagSet.getMask()).isNotEqualTo(MaskedFlag.mask(getFirst())); assertThat(flagSet.getMaskWith(getFirst())).isEqualTo(MaskedFlag.mask(flag, getFirst())); } + // Here to help codecov pick up the toString() method + if (flagSet instanceof AbstractFlagSet) { + //noinspection unchecked + final AbstractFlagSet abstractFlagSet = (AbstractFlagSet) flagSet; + assertThat(abstractFlagSet.toString()).isNotNull().doesNotStartWith("@"); + } assertThat(flagSet.toString()).isNotNull(); assertThat(flag.name()).isNotNull(); assertThat(flag.isSet(flag)).isTrue(); diff --git a/src/test/java/org/lmdbjava/ByteBufferProxyTest.java b/src/test/java/org/lmdbjava/ByteBufferProxyTest.java index 2e4ed823..575ce346 100644 --- a/src/test/java/org/lmdbjava/ByteBufferProxyTest.java +++ b/src/test/java/org/lmdbjava/ByteBufferProxyTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.lang.Integer.BYTES; @@ -63,7 +62,8 @@ void buffersMustBeDirect() { () -> { try (final TempDir tempDir = new TempDir()) { final Path dir = tempDir.createTempDir(); - try (Env env = create().setMaxReaders(1).open(dir)) { + try (Env env = + create().setSafeClose().setSafeClose().setMaxReaders(1).open(dir)) { final Dbi db = env.createDbi() .setDbName(DB_1) diff --git a/src/test/java/org/lmdbjava/CursorDeprecatedTest.java b/src/test/java/org/lmdbjava/CursorDeprecatedTest.java index a4528577..a395397e 100644 --- a/src/test/java/org/lmdbjava/CursorDeprecatedTest.java +++ b/src/test/java/org/lmdbjava/CursorDeprecatedTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,6 +40,7 @@ import java.nio.file.Path; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.lmdbjava.Txn.NotReadyException; import org.lmdbjava.Txn.ReadOnlyRequiredException; @@ -62,6 +63,7 @@ void beforeEach() { Path file = tempDir.createTempFile(); env = create(PROXY_OPTIMAL) + .setSafeClose() .setMapSize(MEBIBYTES.toBytes(1)) .setMaxReaders(1) .setMaxDbs(1) @@ -90,13 +92,14 @@ void count() { } } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void cursorCannotCloseIfTransactionCommitted() { assertThatThrownBy( () -> { final Dbi db = env.openDbi(DB_1, MDB_CREATE, MDB_DUPSORT); try (Txn txn = env.txnWrite()) { - try (Cursor c = db.openCursor(txn); ) { + try (Cursor c = db.openCursor(txn)) { c.put(bb(1), bb(2), new PutFlags[] {MDB_APPENDDUP}); assertThat(c.count()).isEqualTo(1L); c.put(bb(1), bb(4), new PutFlags[] {MDB_APPENDDUP}); diff --git a/src/test/java/org/lmdbjava/CursorIterableIntegerKeyTest.java b/src/test/java/org/lmdbjava/CursorIterableIntegerKeyTest.java index c562ed15..175cd5d3 100644 --- a/src/test/java/org/lmdbjava/CursorIterableIntegerKeyTest.java +++ b/src/test/java/org/lmdbjava/CursorIterableIntegerKeyTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -102,6 +102,7 @@ public void before() throws IOException { final BufferProxy bufferProxy = ByteBufferProxy.PROXY_OPTIMAL; env = Env.create(bufferProxy) + .setSafeClose() .setMapSize(256, ByteUnit.KIBIBYTES) .setMaxReaders(1) .setMaxDbs(3) @@ -122,17 +123,18 @@ public void testNumericOrderLong() { final Dbi dbi = dbiFactory.factory.apply(env); try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - long i = 1; - while (true) { - // System.out.println("putting " + i); - c.put(bbNative(i), bb(i + "-long")); - final long i2 = i * 10; - if (i2 < i) { - // Overflowed - break; + try (Cursor c = dbi.openCursor(txn)) { + long i = 1; + while (true) { + // System.out.println("putting " + i); + c.put(bbNative(i), bb(i + "-long")); + final long i2 = i * 10; + if (i2 < i) { + // Overflowed + break; + } + i = i2; } - i = i2; } txn.commit(); } @@ -165,17 +167,18 @@ public void testNumericOrderInt() { final Dbi dbi = dbiFactory.factory.apply(env); try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - int i = 1; - while (true) { - // System.out.println("putting " + i); - c.put(bbNative(i), bb(i + "-int")); - final int i2 = i * 10; - if (i2 < i) { - // Overflowed - break; + try (Cursor c = dbi.openCursor(txn)) { + int i = 1; + while (true) { + // System.out.println("putting " + i); + c.put(bbNative(i), bb(i + "-int")); + final int i2 = i * 10; + if (i2 < i) { + // Overflowed + break; + } + i = i2; } - i = i2; } txn.commit(); } @@ -279,11 +282,12 @@ private void populateTestDataList() { private void populateDatabase(final Dbi dbi) { try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - c.put(bbNative(2), bb(3), MDB_NOOVERWRITE); - c.put(bbNative(4), bb(5)); - c.put(bbNative(6), bb(7)); - c.put(bbNative(8), bb(9)); + try (Cursor c = dbi.openCursor(txn)) { + c.put(bbNative(2), bb(3), MDB_NOOVERWRITE); + c.put(bbNative(4), bb(5)); + c.put(bbNative(6), bb(7)); + c.put(bbNative(8), bb(9)); + } txn.commit(); } } diff --git a/src/test/java/org/lmdbjava/CursorIterablePerfTest.java b/src/test/java/org/lmdbjava/CursorIterablePerfTest.java index 198ffd28..dcf69e59 100644 --- a/src/test/java/org/lmdbjava/CursorIterablePerfTest.java +++ b/src/test/java/org/lmdbjava/CursorIterablePerfTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,6 +48,7 @@ public void before() { final BufferProxy bufferProxy = ByteBufferProxy.PROXY_OPTIMAL; env = create(bufferProxy) + .setSafeClose() .setMapSize(1, ByteUnit.GIBIBYTES) .setMaxReaders(1) .setMaxDbs(3) diff --git a/src/test/java/org/lmdbjava/CursorIterableRangeTest.java b/src/test/java/org/lmdbjava/CursorIterableRangeTest.java index ab76d3fc..b068b554 100644 --- a/src/test/java/org/lmdbjava/CursorIterableRangeTest.java +++ b/src/test/java/org/lmdbjava/CursorIterableRangeTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static org.assertj.core.api.Assertions.assertThat; @@ -261,6 +260,7 @@ private void testCSV( final Path file = tempDir.createTempFile(); try (final Env env = create() + .setSafeClose() .setMapSize(256, ByteUnit.KIBIBYTES) .setMaxReaders(1) .setMaxDbs(1) @@ -329,13 +329,14 @@ private long getLong(final ByteBuffer byteBuffer, final ByteOrder byteOrder) { private BiConsumer, Dbi> createBasicDBPopulator() { return (env, dbi) -> { try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - c.put(bb(0), bb(1)); - c.put(bb(2), bb(3)); - c.put(bb(4), bb(5)); - c.put(bb(6), bb(7)); - c.put(bb(8), bb(9)); - c.put(bb(-2), bb(-1)); + try (Cursor c = dbi.openCursor(txn)) { + c.put(bb(0), bb(1)); + c.put(bb(2), bb(3)); + c.put(bb(4), bb(5)); + c.put(bb(6), bb(7)); + c.put(bb(8), bb(9)); + c.put(bb(-2), bb(-1)); + } txn.commit(); } }; @@ -344,14 +345,15 @@ private BiConsumer, Dbi> createBasicDBPopulator() { private BiConsumer, Dbi> createMultiDBPopulator(final int copies) { return (env, dbi) -> { try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - for (int i = 0; i < copies; i++) { - c.put(bb(0), bb(1 + i)); - c.put(bb(2), bb(3 + i)); - c.put(bb(4), bb(5 + i)); - c.put(bb(6), bb(7 + i)); - c.put(bb(8), bb(9 + i)); - c.put(bb(-2), bb(-1 + i)); + try (Cursor c = dbi.openCursor(txn)) { + for (int i = 0; i < copies; i++) { + c.put(bb(0), bb(1 + i)); + c.put(bb(2), bb(3 + i)); + c.put(bb(4), bb(5 + i)); + c.put(bb(6), bb(7 + i)); + c.put(bb(8), bb(9 + i)); + c.put(bb(-2), bb(-1 + i)); + } } txn.commit(); } @@ -362,14 +364,15 @@ private BiConsumer, Dbi> createMultiIntegerDBPopulat final int copies) { return (env, dbi) -> { try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - for (int i = 0; i < copies; i++) { - c.put(bbNative(0), bb(1 + i)); - c.put(bbNative(2), bb(3 + i)); - c.put(bbNative(4), bb(5 + i)); - c.put(bbNative(6), bb(7 + i)); - c.put(bbNative(8), bb(9 + i)); - c.put(bbNative(-2), bb(-1 + i)); + try (Cursor c = dbi.openCursor(txn)) { + for (int i = 0; i < copies; i++) { + c.put(bbNative(0), bb(1 + i)); + c.put(bbNative(2), bb(3 + i)); + c.put(bbNative(4), bb(5 + i)); + c.put(bbNative(6), bb(7 + i)); + c.put(bbNative(8), bb(9 + i)); + c.put(bbNative(-2), bb(-1 + i)); + } } txn.commit(); } @@ -380,14 +383,15 @@ private BiConsumer, Dbi> createMultiLongDBPopulator( final int copies) { return (env, dbi) -> { try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - for (int i = 0; i < copies; i++) { - c.put(bbNative(0L), bb(1 + i)); - c.put(bbNative(2L), bb(3 + i)); - c.put(bbNative(4L), bb(5 + i)); - c.put(bbNative(6L), bb(7 + i)); - c.put(bbNative(8L), bb(9 + i)); - c.put(bbNative(-2L), bb(-1 + i)); + try (Cursor c = dbi.openCursor(txn)) { + for (int i = 0; i < copies; i++) { + c.put(bbNative(0L), bb(1 + i)); + c.put(bbNative(2L), bb(3 + i)); + c.put(bbNative(4L), bb(5 + i)); + c.put(bbNative(6L), bb(7 + i)); + c.put(bbNative(8L), bb(9 + i)); + c.put(bbNative(-2L), bb(-1 + i)); + } } txn.commit(); } @@ -397,12 +401,13 @@ private BiConsumer, Dbi> createMultiLongDBPopulator( private BiConsumer, Dbi> createIntegerDBPopulator() { return (env, dbi) -> { try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - c.put(bbNative(0), bb(1)); - c.put(bbNative(1000), bb(2)); - c.put(bbNative(1000000), bb(3)); - c.put(bbNative(-1000000), bb(4)); - c.put(bbNative(-1000), bb(5)); + try (Cursor c = dbi.openCursor(txn)) { + c.put(bbNative(0), bb(1)); + c.put(bbNative(1000), bb(2)); + c.put(bbNative(1000000), bb(3)); + c.put(bbNative(-1000000), bb(4)); + c.put(bbNative(-1000), bb(5)); + } txn.commit(); } }; @@ -411,12 +416,13 @@ private BiConsumer, Dbi> createIntegerDBPopulator() private BiConsumer, Dbi> createLongDBPopulator() { return (env, dbi) -> { try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - c.put(bbNative(0L), bb(1)); - c.put(bbNative(1000L), bb(2)); - c.put(bbNative(1000000L), bb(3)); - c.put(bbNative(-1000000L), bb(4)); - c.put(bbNative(-1000L), bb(5)); + try (Cursor c = dbi.openCursor(txn)) { + c.put(bbNative(0L), bb(1)); + c.put(bbNative(1000L), bb(2)); + c.put(bbNative(1000000L), bb(3)); + c.put(bbNative(-1000000L), bb(4)); + c.put(bbNative(-1000L), bb(5)); + } txn.commit(); } }; diff --git a/src/test/java/org/lmdbjava/CursorIterableTest.java b/src/test/java/org/lmdbjava/CursorIterableTest.java index d90c3c23..0311ecd2 100644 --- a/src/test/java/org/lmdbjava/CursorIterableTest.java +++ b/src/test/java/org/lmdbjava/CursorIterableTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.util.Arrays.asList; @@ -60,6 +59,7 @@ import java.util.stream.Stream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.Parameter; @@ -94,6 +94,7 @@ void beforeEach() { final BufferProxy bufferProxy = ByteBufferProxy.PROXY_OPTIMAL; env = create(bufferProxy) + .setSafeClose() .setMapSize(256, ByteUnit.KIBIBYTES) .setMaxReaders(1) .setMaxDbs(3) @@ -116,15 +117,21 @@ private void populateTestDataList() { private void populateDatabase(final Dbi dbi) { try (Txn txn = env.txnWrite()) { - final Cursor c = dbi.openCursor(txn); - c.put(bb(2), bb(3), MDB_NOOVERWRITE); - c.put(bb(4), bb(5)); - c.put(bb(6), bb(7)); - c.put(bb(8), bb(9)); + try (final Cursor cursor = dbi.openCursor(txn)) { + cursor.put(bb(2), bb(3), MDB_NOOVERWRITE); + cursor.put(bb(4), bb(5)); + cursor.put(bb(6), bb(7)); + cursor.put(bb(8), bb(9)); + } txn.commit(); } } + @Test + void testPopulate() { + getDb(); + } + @Test void allBackwardTest() { verify(allBackward(), 8, 6, 4, 2); @@ -347,6 +354,7 @@ void removeOddElements() { verify(db, all(), 4, 8); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void nextWithClosedEnvTest() { assertThatThrownBy( @@ -364,6 +372,7 @@ void nextWithClosedEnvTest() { .isInstanceOf(Env.AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void removeWithClosedEnvTest() { assertThatThrownBy( @@ -384,6 +393,7 @@ void removeWithClosedEnvTest() { .isInstanceOf(Env.AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void hasNextWithClosedEnvTest() { assertThatThrownBy( @@ -401,6 +411,7 @@ void hasNextWithClosedEnvTest() { .isInstanceOf(Env.AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void forEachRemainingWithClosedEnvTest() { assertThatThrownBy( diff --git a/src/test/java/org/lmdbjava/CursorParamTest.java b/src/test/java/org/lmdbjava/CursorParamTest.java index 28f60419..ec5c93ca 100644 --- a/src/test/java/org/lmdbjava/CursorParamTest.java +++ b/src/test/java/org/lmdbjava/CursorParamTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.lang.Long.BYTES; @@ -92,7 +91,7 @@ protected AbstractBufferRunner(final BufferProxy proxy) { @Override public final void execute(final Path tmp) { - try (Env env = env(tmp)) { + try (final Env env = env(tmp)) { assertThat(env.getDbiNames()).isEmpty(); final Dbi db = env.createDbi() @@ -170,6 +169,7 @@ public final void execute(final Path tmp) { private Env env(final Path tmp) { return create(proxy) + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxReaders(1) .setMaxDbs(1) diff --git a/src/test/java/org/lmdbjava/CursorTest.java b/src/test/java/org/lmdbjava/CursorTest.java index deb75622..2be3db66 100644 --- a/src/test/java/org/lmdbjava/CursorTest.java +++ b/src/test/java/org/lmdbjava/CursorTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.lang.Long.BYTES; @@ -27,46 +26,61 @@ import static org.lmdbjava.DbiFlags.MDB_DUPSORT; import static org.lmdbjava.Env.create; import static org.lmdbjava.EnvFlags.MDB_NOSUBDIR; +import static org.lmdbjava.EnvFlags.MDB_NOTLS; +import static org.lmdbjava.Library.LIB; import static org.lmdbjava.PutFlags.MDB_APPENDDUP; import static org.lmdbjava.PutFlags.MDB_MULTIPLE; import static org.lmdbjava.PutFlags.MDB_NODUPDATA; import static org.lmdbjava.PutFlags.MDB_NOOVERWRITE; +import static org.lmdbjava.ResultCodeMapper.checkRc; import static org.lmdbjava.SeekOp.MDB_FIRST; import static org.lmdbjava.SeekOp.MDB_GET_BOTH; import static org.lmdbjava.SeekOp.MDB_LAST; import static org.lmdbjava.SeekOp.MDB_NEXT; import static org.lmdbjava.TestUtils.DB_1; import static org.lmdbjava.TestUtils.bb; +import static org.lmdbjava.TestUtils.getEntryCount; +import static org.lmdbjava.TestUtils.getInt; import java.nio.ByteBuffer; import java.nio.file.Path; +import java.util.Objects; +import java.util.function.BiConsumer; import java.util.function.Consumer; +import jnr.ffi.byref.PointerByReference; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.lmdbjava.Cursor.ClosedException; -import org.lmdbjava.Env.AlreadyClosedException; -import org.lmdbjava.Txn.NotReadyException; import org.lmdbjava.Txn.ReadOnlyRequiredException; +import org.mockito.Mockito; /** Test {@link Cursor}. */ public final class CursorTest { private Env env; private TempDir tempDir; + private Path envFile; @BeforeEach void beforeEach() { tempDir = new TempDir(); - Path file = tempDir.createTempFile(); + envFile = tempDir.createTempFile(); + openEnv(); + } + + private void openEnv() { env = create(PROXY_OPTIMAL) + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) - .setMaxReaders(1) + .setMaxReaders(2) .setMaxDbs(1) - .setEnvFlags(MDB_NOSUBDIR) - .open(file); + .setEnvFlags(MDB_NOSUBDIR, MDB_NOTLS) + .setSafeClose() + .open(envFile); } @AfterEach @@ -100,7 +114,7 @@ void closedEnvRejectsSeekFirstCall() { () -> { doEnvClosedTest(null, c -> c.seek(MDB_FIRST)); }) - .isInstanceOf(AlreadyClosedException.class); + .isInstanceOf(Env.EnvInUseException.class); } @Test @@ -109,7 +123,7 @@ void closedEnvRejectsSeekLastCall() { () -> { doEnvClosedTest(null, c -> c.seek(MDB_LAST)); }) - .isInstanceOf(AlreadyClosedException.class); + .isInstanceOf(Env.EnvInUseException.class); } @Test @@ -118,7 +132,7 @@ void closedEnvRejectsSeekNextCall() { () -> { doEnvClosedTest(null, c -> c.seek(MDB_NEXT)); }) - .isInstanceOf(AlreadyClosedException.class); + .isInstanceOf(Env.EnvInUseException.class); } @Test @@ -127,7 +141,7 @@ void closedEnvRejectsCloseCall() { () -> { doEnvClosedTest(null, Cursor::close); }) - .isInstanceOf(AlreadyClosedException.class); + .isInstanceOf(Env.EnvInUseException.class); } @Test @@ -136,7 +150,7 @@ void closedEnvRejectsFirstCall() { () -> { doEnvClosedTest(null, Cursor::first); }) - .isInstanceOf(AlreadyClosedException.class); + .isInstanceOf(Env.EnvInUseException.class); } @Test @@ -145,7 +159,7 @@ void closedEnvRejectsLastCall() { () -> { doEnvClosedTest(null, Cursor::last); }) - .isInstanceOf(AlreadyClosedException.class); + .isInstanceOf(Env.EnvInUseException.class); } @Test @@ -161,7 +175,7 @@ void closedEnvRejectsPrevCall() { }, Cursor::prev); }) - .isInstanceOf(AlreadyClosedException.class); + .isInstanceOf(Env.EnvInUseException.class); } @Test @@ -176,7 +190,7 @@ void closedEnvRejectsDeleteCall() { }, Cursor::delete); }) - .isInstanceOf(AlreadyClosedException.class); + .isInstanceOf(Env.EnvInUseException.class); } @Test @@ -224,27 +238,35 @@ void countWithoutDupsort() { } } + @Disabled // Disabled because we have no way to close the env in afterEach() because we + // can't close the cursor. This is trying to test something that you shouldn't do and that + // leaves @Test void cursorCannotCloseIfTransactionCommitted() { - assertThatThrownBy( - () -> { - final Dbi db = - env.createDbi() - .setDbName(DB_1) - .withDefaultComparator() - .setDbiFlags(MDB_CREATE, MDB_DUPSORT) - .open(); - try (Txn txn = env.txnWrite()) { - try (Cursor c = db.openCursor(txn); ) { - c.put(bb(1), bb(2), MDB_APPENDDUP); - assertThat(c.count()).isEqualTo(1L); - c.put(bb(1), bb(4), MDB_APPENDDUP); - assertThat(c.count()).isEqualTo(2L); - txn.commit(); - } - } - }) - .isInstanceOf(NotReadyException.class); + final Dbi db = + env.createDbi() + .setDbName(DB_1) + .withDefaultComparator() + .setDbiFlags(MDB_CREATE, MDB_DUPSORT) + .open(); + + try (Txn txn = env.txnWrite()) { + Cursor c = db.openCursor(txn); + c.put(bb(1), bb(2), MDB_APPENDDUP); + assertThat(c.count()).isEqualTo(1L); + c.put(bb(1), bb(4), MDB_APPENDDUP); + assertThat(c.count()).isEqualTo(2L); + + assertThat(txn.isReady()).isTrue(); + + txn.commit(); + + assertThat(txn.isReady()).isFalse(); + + // Cursor is not in a ready state to be closed because we have committed + // This makes it impossible to close the cursor and thus the env + assertThatThrownBy(c::close).isInstanceOf(Txn.NotReadyException.class); + } } @Test @@ -475,23 +497,16 @@ void renewTxRo() { @Test void renewTxRw() { - assertThatThrownBy( - () -> { - final Dbi db = - env.createDbi() - .setDbName(DB_1) - .withDefaultComparator() - .setDbiFlags(MDB_CREATE) - .open(); - try (Txn txn = env.txnWrite()) { - assertThat(txn.isReadOnly()).isFalse(); + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); - try (Cursor c = db.openCursor(txn)) { - c.renew(txn); - } - } - }) - .isInstanceOf(ReadOnlyRequiredException.class); + try (Txn txn = env.txnWrite()) { + assertThat(txn.isReadOnly()).isFalse(); + + try (Cursor c = db.openCursor(txn)) { + assertThatThrownBy(() -> c.renew(txn)).isInstanceOf(ReadOnlyRequiredException.class); + } + } } @Test @@ -591,6 +606,156 @@ void testCursorByteBufferDuplicate() { } } + @Test + void testCursorConstructorFailure() { + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + try (Txn txn = env.txnWrite()) { + + // These two lines do what Dbi.openCursor does before calling the Cursor constructor + final PointerByReference cursorPtr = new PointerByReference(); + checkRc(LIB.mdb_cursor_open(txn.pointer(), db.pointer(), cursorPtr)); + + //noinspection unchecked,resource + final Txn mockTxn = (Txn) Mockito.mock(Txn.class); + Mockito.when(mockTxn.newKeyVal()).thenThrow(new RuntimeException("newKeyVal error")); + assertThatThrownBy(() -> new Cursor<>(cursorPtr.getValue(), mockTxn, env)) + .isInstanceOf(RuntimeException.class) + .hasMessage("newKeyVal error"); + } + } + + @Test + void testMultipleROCursorsOneTxn() { + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + db.put(bb(1), bb(10)); + db.put(bb(2), bb(20)); + db.put(bb(2), bb(30)); + db.put(bb(4), bb(40)); + + try (Txn readTxn1 = env.txnRead()) { + + try (Cursor cursor1 = db.openCursor(readTxn1); + Cursor cursor2 = db.openCursor(readTxn1)) { + + // Two independent cursors at different positions + cursor1.seek(MDB_FIRST); + cursor2.seek(MDB_LAST); + + assertThat(cursor1.key()).isEqualTo(bb(1)); + assertThat(cursor2.key()).isEqualTo(bb(4)); + + cursor1.seek(MDB_LAST); + cursor2.seek(MDB_FIRST); + + assertThat(cursor1.key()).isEqualTo(bb(4)); + assertThat(cursor2.key()).isEqualTo(bb(1)); + } + } + } + + @Test + void testMultipleRWCursorsOneTxn() { + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + assertThat(getEntryCount(db, env)).isEqualTo(0); + + db.put(bb(1), bb(10)); + db.put(bb(2), bb(20)); + db.put(bb(3), bb(30)); + db.put(bb(4), bb(40)); + + assertThat(getEntryCount(db, env)).isEqualTo(4); + + try (final Txn writeTxn = env.txnWrite()) { + + assertThat(getEntryCount(db, writeTxn)).isEqualTo(4); + + try (final Cursor cursor1 = db.openCursor(writeTxn); + final Cursor cursor2 = db.openCursor(writeTxn)) { + + // Two independent cursors at different positions + cursor1.seek(MDB_FIRST); + cursor2.seek(MDB_LAST); + + assertThat(getInt(cursor1.key())).isEqualTo(1); + assertThat(getInt(cursor2.key())).isEqualTo(4); + + cursor1.delete(); + cursor1.seek(MDB_FIRST); + assertThat(getInt(cursor1.key())).isEqualTo(2); + assertThat(getInt(cursor2.key())).isEqualTo(4); + + cursor2.delete(); + cursor2.seek(MDB_LAST); + assertThat(getInt(cursor2.key())).isEqualTo(3); + + assertThat(getEntryCount(db, writeTxn)).isEqualTo(2); + + // This uses a separate read txn so can't sse the deletes + assertThat(getEntryCount(db, env)).isEqualTo(4); + } + } + } + + @Test + void testNonReadyTxnRejectsLast() { + doNonReadyTxnTest(Cursor::last); + } + + @Test + void testNonReadyTxnRejectsNext() { + doNonReadyTxnTest(Cursor::next); + } + + @Test + void testNonReadyTxnRejectsPrev() { + doNonReadyTxnTest(Cursor::prev); + } + + @Test + void testNonReadyTxnRejectsPut() { + doNonReadyTxnTest(c -> c.put(bb(5), bb(6))); + } + + @Test + void testNonReadyTxnRejectsPutMultiple() { + doNonReadyTxnTest(c -> c.putMultiple(bb(5), bb(6), 1, MDB_MULTIPLE)); + } + + @Test + void testNonReadyTxnRejectsSeek() { + doNonReadyTxnTest(c -> c.seek(MDB_FIRST)); + } + + private void doNonReadyTxnTest(final Consumer> work) { + doCursorTest( + true, + (txn, c) -> { + txn.abort(); + assertThatThrownBy(() -> work.accept(c)).isInstanceOf(Txn.NotReadyException.class); + c.close(); + }); + openEnv(); + doCursorTest( + true, + (txn, c) -> { + txn.close(); + assertThatThrownBy(() -> work.accept(c)).isInstanceOf(Txn.NotReadyException.class); + c.close(); + }); + openEnv(); + doCursorTest( + true, + (txn, c) -> { + txn.abort(); + assertThatThrownBy(() -> work.accept(c)).isInstanceOf(Txn.NotReadyException.class); + c.close(); + }); + } + private void doEnvClosedTest( final Consumer> workBeforeEnvClosed, final Consumer> workAfterEnvClose) { @@ -599,7 +764,7 @@ private void doEnvClosedTest( db.put(bb(1), bb(10)); db.put(bb(2), bb(20)); - db.put(bb(2), bb(30)); + db.put(bb(3), bb(30)); db.put(bb(4), bb(40)); try (Txn txn = env.txnWrite()) { @@ -617,4 +782,29 @@ private void doEnvClosedTest( } } } + + private void doCursorTest( + final boolean readOnly, final BiConsumer, Cursor> work) { + Objects.requireNonNull(work); + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + db.put(bb(1), bb(10)); + db.put(bb(2), bb(20)); + db.put(bb(3), bb(30)); + db.put(bb(4), bb(40)); + + final TxnFlagSet txnFlagSet = readOnly ? TxnFlags.MDB_RDONLY_TXN : TxnFlagSet.EMPTY; + + try (Txn txn = env.txn(null, txnFlagSet)) { + Cursor c = db.openCursor(txn); + try { + work.accept(txn, c); + } finally { + if (txn.isReadOnly() || txn.isReady()) { + c.close(); + } + } + } + } } diff --git a/src/test/java/org/lmdbjava/DbiBuilderTest.java b/src/test/java/org/lmdbjava/DbiBuilderTest.java index c06c3dc9..7685cc10 100644 --- a/src/test/java/org/lmdbjava/DbiBuilderTest.java +++ b/src/test/java/org/lmdbjava/DbiBuilderTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,6 +42,7 @@ public void before() { tempDir = new TempDir(); env = create() + .setSafeClose() .setMapSize(64, ByteUnit.MEBIBYTES) .setMaxReaders(2) .setMaxDbs(2) diff --git a/src/test/java/org/lmdbjava/DbiDeprecatedTest.java b/src/test/java/org/lmdbjava/DbiDeprecatedTest.java index 7156b963..b7c4c422 100644 --- a/src/test/java/org/lmdbjava/DbiDeprecatedTest.java +++ b/src/test/java/org/lmdbjava/DbiDeprecatedTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,6 +51,7 @@ import java.util.function.ToIntFunction; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.lmdbjava.CursorIterable.KeyVal; import org.lmdbjava.Dbi.DbFullException; @@ -78,6 +79,7 @@ void beforeEach() { final Path file = tempDir.createTempFile(); env = create() + .setSafeClose() .setMapSize(MEBIBYTES.toBytes(64)) .setMaxReaders(2) .setMaxDbs(2) @@ -86,6 +88,7 @@ void beforeEach() { final Path fileBa = tempDirBa.createTempFile(); envBa = create(PROXY_BA) + .setSafeClose() .setMapSize(MEBIBYTES.toBytes(64)) .setMaxReaders(2) .setMaxDbs(2) @@ -372,6 +375,7 @@ void putCommitGetByteArray() { final Path file = tempDir.createTempFile(); try (Env envBa = create(PROXY_BA) + .setSafeClose() .setMapSize(MEBIBYTES.toBytes(64)) .setMaxReaders(1) .setMaxDbs(2) @@ -550,6 +554,7 @@ void closedEnvRejectsOpenCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsCloseCall() { assertThatThrownBy( @@ -559,6 +564,7 @@ void closedEnvRejectsCloseCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsGetCall() { assertThatThrownBy( @@ -573,6 +579,7 @@ void closedEnvRejectsGetCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsPutCall() { assertThatThrownBy( @@ -582,6 +589,7 @@ void closedEnvRejectsPutCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsPutWithTxnCall() { assertThatThrownBy( @@ -595,6 +603,7 @@ void closedEnvRejectsPutWithTxnCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsIterateCall() { assertThatThrownBy( @@ -604,6 +613,7 @@ void closedEnvRejectsIterateCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsDropCall() { assertThatThrownBy( @@ -613,6 +623,7 @@ void closedEnvRejectsDropCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsDropAndDeleteCall() { assertThatThrownBy( @@ -622,6 +633,7 @@ void closedEnvRejectsDropAndDeleteCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsOpenCursorCall() { assertThatThrownBy( @@ -631,6 +643,7 @@ void closedEnvRejectsOpenCursorCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsReserveCall() { assertThatThrownBy( @@ -640,6 +653,7 @@ void closedEnvRejectsReserveCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsStatCall() { assertThatThrownBy( diff --git a/src/test/java/org/lmdbjava/DbiTest.java b/src/test/java/org/lmdbjava/DbiTest.java index 575937f5..28834bb0 100644 --- a/src/test/java/org/lmdbjava/DbiTest.java +++ b/src/test/java/org/lmdbjava/DbiTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.lang.Long.MAX_VALUE; @@ -61,6 +60,7 @@ import java.util.function.ToIntFunction; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.lmdbjava.CursorIterable.KeyVal; import org.lmdbjava.Dbi.DbFullException; @@ -81,6 +81,7 @@ void beforeEach() { final Path file = tempDir.createTempFile(); env = create() + .setSafeClose() .setMapSize(64, ByteUnit.MEBIBYTES) .setMaxReaders(2) .setMaxDbs(2) @@ -89,6 +90,7 @@ void beforeEach() { final Path fileBa = tempDir.createTempFile(); envBa = create(PROXY_BA) + .setSafeClose() .setMapSize(64, ByteUnit.MEBIBYTES) .setMaxReaders(2) .setMaxDbs(2) @@ -492,6 +494,7 @@ void putCommitGetByteArray() { final Path file = tempDir.createTempFile(); try (Env envBa = create(PROXY_BA) + .setSafeClose() .setMapSize(64, ByteUnit.MEBIBYTES) .setMaxReaders(1) .setMaxDbs(2) @@ -697,6 +700,7 @@ void closedEnvRejectsOpenCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsCloseCall() { assertThatThrownBy( @@ -706,6 +710,7 @@ void closedEnvRejectsCloseCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsGetCall() { assertThatThrownBy( @@ -721,6 +726,7 @@ void closedEnvRejectsGetCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsPutCall() { assertThatThrownBy( @@ -730,6 +736,7 @@ void closedEnvRejectsPutCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsPutWithTxnCall() { assertThatThrownBy( @@ -743,6 +750,7 @@ void closedEnvRejectsPutWithTxnCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsIterateCall() { assertThatThrownBy( @@ -752,6 +760,7 @@ void closedEnvRejectsIterateCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsDropCall() { assertThatThrownBy( @@ -761,6 +770,7 @@ void closedEnvRejectsDropCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsDropAndDeleteCall() { assertThatThrownBy( @@ -770,6 +780,7 @@ void closedEnvRejectsDropAndDeleteCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsOpenCursorCall() { assertThatThrownBy( @@ -779,6 +790,7 @@ void closedEnvRejectsOpenCursorCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsReserveCall() { assertThatThrownBy( @@ -788,6 +800,7 @@ void closedEnvRejectsReserveCall() { .isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void closedEnvRejectsStatCall() { assertThatThrownBy( diff --git a/src/test/java/org/lmdbjava/EnvDeprecatedTest.java b/src/test/java/org/lmdbjava/EnvDeprecatedTest.java index da004cc8..9e959ee1 100644 --- a/src/test/java/org/lmdbjava/EnvDeprecatedTest.java +++ b/src/test/java/org/lmdbjava/EnvDeprecatedTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,6 +69,7 @@ void byteUnit() { final Path file = tempDir.createTempFile(); try (Env env = Env.create() + .setSafeClose() .setMaxReaders(1) .setMapSize(MEBIBYTES.toBytes(1)) .open(file.toFile(), MDB_NOSUBDIR)) { @@ -82,7 +83,7 @@ void cannotChangeMapSizeAfterOpen() { assertThatThrownBy( () -> { final Path file = tempDir.createTempFile(); - final Builder builder = Env.create().setMaxReaders(1); + final Builder builder = Env.create().setSafeClose().setMaxReaders(1); try (Env env = builder.open(file.toFile(), MDB_NOSUBDIR)) { builder.setMapSize(1); } @@ -95,7 +96,7 @@ void cannotChangeMaxDbsAfterOpen() { assertThatThrownBy( () -> { final Path file = tempDir.createTempFile(); - final Builder builder = Env.create().setMaxReaders(1); + final Builder builder = Env.create().setSafeClose().setMaxReaders(1); try (Env env = builder.open(file.toFile(), MDB_NOSUBDIR)) { builder.setMaxDbs(1); } @@ -108,7 +109,7 @@ void cannotChangeMaxReadersAfterOpen() { assertThatThrownBy( () -> { final Path file = tempDir.createTempFile(); - final Builder builder = Env.create().setMaxReaders(1); + final Builder builder = Env.create().setSafeClose().setMaxReaders(1); try (Env env = builder.open(file.toFile(), MDB_NOSUBDIR)) { builder.setMaxReaders(1); } @@ -122,7 +123,7 @@ void cannotInfoOnceClosed() { () -> { final Path file = tempDir.createTempFile(); final Env env = - Env.create().setMaxReaders(1).open(file.toFile(), MDB_NOSUBDIR); + Env.create().setSafeClose().setMaxReaders(1).open(file.toFile(), MDB_NOSUBDIR); env.close(); env.info(); }) @@ -134,7 +135,7 @@ void cannotOpenTwice() { assertThatThrownBy( () -> { final Path file = tempDir.createTempFile(); - final Builder builder = Env.create().setMaxReaders(1); + final Builder builder = Env.create().setSafeClose().setMaxReaders(1); builder.open(file.toFile(), MDB_NOSUBDIR).close(); builder.open(file.toFile(), MDB_NOSUBDIR); }) @@ -147,7 +148,7 @@ void cannotStatOnceClosed() { () -> { final Path file = tempDir.createTempFile(); final Env env = - Env.create().setMaxReaders(1).open(file.toFile(), MDB_NOSUBDIR); + Env.create().setSafeClose().setMaxReaders(1).open(file.toFile(), MDB_NOSUBDIR); env.close(); env.stat(); }) @@ -160,7 +161,7 @@ void cannotSyncOnceClosed() { () -> { final Path file = tempDir.createTempFile(); final Env env = - Env.create().setMaxReaders(1).open(file.toFile(), MDB_NOSUBDIR); + Env.create().setSafeClose().setMaxReaders(1).open(file.toFile(), MDB_NOSUBDIR); env.close(); env.sync(false); }) @@ -174,7 +175,7 @@ void copyDirectoryBased() { assertThat(Files.exists(dest)).isTrue(); assertThat(Files.isDirectory(dest)).isTrue(); assertThat(FileUtil.count(dest)).isEqualTo(0); - try (Env env = Env.create().setMaxReaders(1).open(src.toFile())) { + try (Env env = Env.create().setSafeClose().setMaxReaders(1).open(src.toFile())) { env.copy(dest.toFile(), MDB_CP_COMPACT); assertThat(FileUtil.count(dest)).isEqualTo(1); } @@ -187,7 +188,8 @@ void copyDirectoryRejectsFileDestination() { final Path dest = tempDir.createTempDir(); final Path src = tempDir.createTempDir(); FileUtil.deleteDir(dest); - try (Env env = Env.create().setMaxReaders(1).open(src.toFile())) { + try (Env env = + Env.create().setSafeClose().setMaxReaders(1).open(src.toFile())) { env.copy(dest.toFile(), MDB_CP_COMPACT); } }) @@ -202,7 +204,8 @@ void copyDirectoryRejectsMissingDestination() { () -> { try { Files.delete(dest); - try (Env env = Env.create().setMaxReaders(1).open(src.toFile())) { + try (Env env = + Env.create().setSafeClose().setMaxReaders(1).open(src.toFile())) { env.copy(dest.toFile(), MDB_CP_COMPACT); } } catch (final IOException e) { @@ -222,7 +225,8 @@ void copyDirectoryRejectsNonEmptyDestination() { final Path subDir = dest.resolve("hello"); Files.createDirectory(subDir); assertThat(Files.isDirectory(subDir)).isTrue(); - try (Env env = Env.create().setMaxReaders(1).open(src.toFile())) { + try (Env env = + Env.create().setSafeClose().setMaxReaders(1).open(src.toFile())) { env.copy(dest.toFile(), MDB_CP_COMPACT); } } catch (final IOException e) { @@ -237,7 +241,8 @@ void copyFileBased() { final Path dest = tempDir.createTempFile(); final Path src = tempDir.createTempFile(); assertThat(Files.exists(dest)).isFalse(); - try (Env env = Env.create().setMaxReaders(1).open(src.toFile(), MDB_NOSUBDIR)) { + try (Env env = + Env.create().setSafeClose().setMaxReaders(1).open(src.toFile(), MDB_NOSUBDIR)) { env.copy(dest.toFile(), MDB_CP_COMPACT); } assertThat(FileUtil.size(dest)).isGreaterThan(0L); @@ -252,7 +257,7 @@ void copyFileRejectsExistingDestination() { Files.createFile(dest); assertThat(Files.exists(dest)).isTrue(); try (Env env = - Env.create().setMaxReaders(1).open(src.toFile(), MDB_NOSUBDIR)) { + Env.create().setSafeClose().setMaxReaders(1).open(src.toFile(), MDB_NOSUBDIR)) { env.copy(dest.toFile(), MDB_CP_COMPACT); } }) @@ -264,6 +269,7 @@ void createAsFile() { final Path file = tempDir.createTempFile(); try (Env env = Env.create() + .setSafeClose() .setMapSize(MEBIBYTES.toBytes(1)) .setMaxDbs(1) .setMaxReaders(1) @@ -284,6 +290,7 @@ void mapFull() { final Random rnd = new Random(); try (Env env = Env.create() + .setSafeClose() .setMaxReaders(1) .setMapSize(MEBIBYTES.toBytes(8)) .setMaxDbs(1) @@ -304,11 +311,12 @@ void mapFull() { @Test void readOnlySupported() { final Path dir = tempDir.createTempDir(); - try (Env rwEnv = Env.create().setMaxReaders(1).open(dir.toFile())) { + try (Env rwEnv = Env.create().setSafeClose().setMaxReaders(1).open(dir.toFile())) { final Dbi rwDb = rwEnv.openDbi(DB_1, MDB_CREATE); rwDb.put(bb(1), bb(42)); } - try (Env roEnv = Env.create().setMaxReaders(1).open(dir.toFile(), MDB_RDONLY_ENV)) { + try (Env roEnv = + Env.create().setSafeClose().setMaxReaders(1).open(dir.toFile(), MDB_RDONLY_ENV)) { final Dbi roDb = roEnv.openDbi(DB_1); try (Txn roTxn = roEnv.txnRead()) { assertThat(roDb.get(roTxn, bb(1))).isNotNull(); @@ -325,6 +333,7 @@ void setMapSize() { final Random rnd = new Random(); try (Env env = Env.create() + .setSafeClose() .setMaxReaders(1) .setMapSize(KIBIBYTES.toBytes(256)) .setMaxDbs(1) diff --git a/src/test/java/org/lmdbjava/EnvTest.java b/src/test/java/org/lmdbjava/EnvTest.java index 69a5ea4e..ed8b7b11 100644 --- a/src/test/java/org/lmdbjava/EnvTest.java +++ b/src/test/java/org/lmdbjava/EnvTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.nio.ByteBuffer.allocateDirect; @@ -26,6 +25,7 @@ import static org.lmdbjava.EnvFlags.MDB_NOSYNC; import static org.lmdbjava.EnvFlags.MDB_NOTLS; import static org.lmdbjava.EnvFlags.MDB_RDONLY_ENV; +import static org.lmdbjava.PutFlags.MDB_APPENDDUP; import static org.lmdbjava.TestUtils.DB_1; import static org.lmdbjava.TestUtils.bb; @@ -34,15 +34,24 @@ import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Random; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; +import java.util.stream.Stream; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.lmdbjava.Env.AlreadyClosedException; import org.lmdbjava.Env.AlreadyOpenException; import org.lmdbjava.Env.Builder; @@ -70,6 +79,7 @@ void byteUnit() { final Path file = tempDir.createTempFile(); try (Env env = Env.create() + .setSafeClose() .setMaxReaders(1) .setMapSize(1, ByteUnit.MEBIBYTES) .setEnvFlags(MDB_NOSUBDIR) @@ -80,93 +90,57 @@ void byteUnit() { } @Test - void cannotChangeMapSizeAfterOpen() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - final Builder builder = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR); - try (Env ignored = builder.setEnvFlags(MDB_NOSUBDIR).open(file)) { - builder.setMapSize(1); - } - }) - .isInstanceOf(AlreadyOpenException.class); - } - - @Test - void cannotChangePermissionsAfterOpen() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - final Builder builder = - Env.create().setFilePermissions(0666).setEnvFlags(MDB_NOSUBDIR); - try (Env ignored = builder.setEnvFlags(MDB_NOSUBDIR).open(file)) { - builder.setFilePermissions(0664); - } - }) - .isInstanceOf(AlreadyOpenException.class); - } - - @Test - void cannotChangeMaxDbsAfterOpen() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - final Builder builder = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR); - try (Env ignored = builder.setEnvFlags(MDB_NOSUBDIR).open(file)) { - builder.setMaxDbs(1); - } - }) - .isInstanceOf(AlreadyOpenException.class); - } + void cannotChangeBuilderAfterOpen() { + final Path file = tempDir.createTempFile(); + final Builder builder = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR); + try (Env ignored = builder.open(file)) { - @Test - void cannotChangeMaxReadersAfterOpen() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - final Builder builder = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR); - try (Env ignored = builder.setEnvFlags(MDB_NOSUBDIR).open(file)) { - builder.setMaxReaders(1); - } - }) - .isInstanceOf(AlreadyOpenException.class); + // Now try to modify the builder after it has been used to open an Env + assertThatThrownBy(() -> builder.setMapSize(1)).isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(builder::setSafeClose).isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.setSafeClose(true)).isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(builder::setSingleThreaded).isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.setSingleThreaded(true)) + .isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.setEnvFlags(EnvFlagSet.of(MDB_NOSUBDIR))) + .isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.setMaxReaders(1)).isInstanceOf(AlreadyOpenException.class); + //noinspection OctalInteger + assertThatThrownBy(() -> builder.setFilePermissions(0666)) + .isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.setMaxDbs(1)).isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.setMapSize(1, ByteUnit.MEBIBYTES)) + .isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.addEnvFlag(MDB_NOSYNC)) + .isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.addEnvFlags(EnvFlagSet.of(MDB_NOSYNC))) + .isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.addEnvFlags(Collections.singleton(MDB_NOSYNC))) + .isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.setEnvFlags(MDB_NOSYNC)) + .isInstanceOf(AlreadyOpenException.class); + assertThatThrownBy(() -> builder.setEnvFlags(Collections.singleton(MDB_NOSYNC))) + .isInstanceOf(AlreadyOpenException.class); + //noinspection resource + assertThatThrownBy(() -> builder.open(file)).isInstanceOf(AlreadyOpenException.class); + } } @Test void cannotInfoOnceClosed() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - final Env env = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); - env.close(); - env.info(); - }) - .isInstanceOf(AlreadyClosedException.class); - } - - @Test - void cannotOpenTwice() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - final Builder builder = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR); - builder.open(file).close(); - //noinspection resource // This will fail to open - builder.open(file); - }) - .isInstanceOf(AlreadyOpenException.class); + final Path file = tempDir.createTempFile(); + final Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); + env.close(); + assertThatThrownBy(env::info).isInstanceOf(AlreadyClosedException.class); } @Test void cannotOverflowMapSize() { assertThatThrownBy( () -> { - final Builder builder = Env.create().setMaxReaders(1); + final Builder builder = Env.create().setSafeClose().setMaxReaders(1); final int mb = 1_024 * 1_024; //noinspection NumericOverflow // Intentional overflow final int size = mb * 2_048; // as per issue 18 @@ -179,7 +153,7 @@ void cannotOverflowMapSize() { void negativeMapSize() { assertThatThrownBy( () -> { - final Builder builder = Env.create().setMaxReaders(1); + final Builder builder = Env.create().setSafeClose().setMaxReaders(1); builder.setMapSize(-1); }) .isInstanceOf(IllegalArgumentException.class); @@ -189,7 +163,7 @@ void negativeMapSize() { void negativeMapSize2() { assertThatThrownBy( () -> { - final Builder builder = Env.create().setMaxReaders(1); + final Builder builder = Env.create().setSafeClose().setMaxReaders(1); builder.setMapSize(-1, ByteUnit.MEBIBYTES); }) .isInstanceOf(IllegalArgumentException.class); @@ -197,27 +171,48 @@ void negativeMapSize2() { @Test void cannotStatOnceClosed() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - final Env env = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); - env.close(); - env.stat(); - }) - .isInstanceOf(AlreadyClosedException.class); + final Path file = tempDir.createTempFile(); + final Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); + env.close(); + assertThatThrownBy(env::stat).isInstanceOf(AlreadyClosedException.class); } @Test void cannotSyncOnceClosed() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - final Env env = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); - env.close(); - env.sync(false); - }) + final Path file = tempDir.createTempFile(); + final Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); + env.close(); + assertThatThrownBy(() -> env.sync(false)).isInstanceOf(AlreadyClosedException.class); + } + + @Test + void cannotOpenReadTxnOnceClosed() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); + env.close(); + assertThatThrownBy(env::txnRead).isInstanceOf(AlreadyClosedException.class); + } + + @Test + void cannotOpenWriteTxnOnceClosed() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); + env.close(); + assertThatThrownBy(env::txnWrite).isInstanceOf(AlreadyClosedException.class); + } + + @Test + void cannotOpenTxnOnceClosed() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file); + env.close(); + assertThatThrownBy(() -> env.txn(null)).isInstanceOf(AlreadyClosedException.class); + assertThatThrownBy(() -> env.txn(null, TxnFlags.MDB_RDONLY_TXN)) .isInstanceOf(AlreadyClosedException.class); } @@ -228,7 +223,7 @@ void copyDirectoryBased() { assertThat(Files.isDirectory(dest)).isTrue(); assertThat(FileUtil.count(dest)).isEqualTo(0); final Path src = tempDir.createTempDir(); - try (Env env = Env.create().setMaxReaders(1).open(src)) { + try (Env env = Env.create().setSafeClose().setMaxReaders(1).open(src)) { env.copy(dest, MDB_CP_COMPACT); assertThat(FileUtil.count(dest)).isEqualTo(1); } @@ -241,7 +236,7 @@ void copyDirectoryBased_noFlags() { assertThat(Files.isDirectory(dest)).isTrue(); assertThat(FileUtil.count(dest)).isEqualTo(0); final Path src = tempDir.createTempDir(); - try (Env env = Env.create().setMaxReaders(1).open(src)) { + try (Env env = Env.create().setSafeClose().setMaxReaders(1).open(src)) { env.copy(dest); assertThat(FileUtil.count(dest)).isEqualTo(1); } @@ -249,54 +244,41 @@ void copyDirectoryBased_noFlags() { @Test void copyDirectoryRejectsFileDestination() { - assertThatThrownBy( - () -> { - final Path dest = tempDir.createTempDir(); - FileUtil.deleteDir(dest); - final Path src = tempDir.createTempDir(); - try (Env env = Env.create().setMaxReaders(1).open(src)) { - env.copy(dest, MDB_CP_COMPACT); - } - }) - .isInstanceOf(InvalidCopyDestination.class); + final Path dest = tempDir.createTempDir(); + FileUtil.deleteDir(dest); + final Path src = tempDir.createTempDir(); + try (Env env = Env.create().setSafeClose().setMaxReaders(1).open(src)) { + assertThatThrownBy(() -> env.copy(dest, MDB_CP_COMPACT)) + .isInstanceOf(InvalidCopyDestination.class); + } } @Test void copyDirectoryRejectsMissingDestination() { - assertThatThrownBy( - () -> { - final Path dest = tempDir.createTempDir(); - try { - Files.delete(dest); - final Path src = tempDir.createTempDir(); - try (Env env = Env.create().setMaxReaders(1).open(src)) { - env.copy(dest, MDB_CP_COMPACT); - } - } catch (final IOException e) { - throw new UncheckedIOException(e); - } - }) - .isInstanceOf(InvalidCopyDestination.class); + final Path dest = tempDir.createTempDir(); + try { + Files.delete(dest); + final Path src = tempDir.createTempDir(); + try (Env env = Env.create().setSafeClose().setMaxReaders(1).open(src)) { + assertThatThrownBy(() -> env.copy(dest, MDB_CP_COMPACT)) + .isInstanceOf(InvalidCopyDestination.class); + } + } catch (final IOException e) { + throw new UncheckedIOException(e); + } } @Test - void copyDirectoryRejectsNonEmptyDestination() { - assertThatThrownBy( - () -> { - final Path dest = tempDir.createTempDir(); - try { - final Path subDir = dest.resolve("hello"); - Files.createDirectory(subDir); - assertThat(Files.isDirectory(subDir)).isTrue(); - final Path src = tempDir.createTempDir(); - try (Env env = Env.create().setMaxReaders(1).open(src)) { - env.copy(dest, MDB_CP_COMPACT); - } - } catch (final IOException e) { - throw new UncheckedIOException(e); - } - }) - .isInstanceOf(InvalidCopyDestination.class); + void copyDirectoryRejectsNonEmptyDestination() throws IOException { + final Path dest = tempDir.createTempDir(); + final Path subDir = dest.resolve("hello"); + Files.createDirectory(subDir); + assertThat(Files.isDirectory(subDir)).isTrue(); + final Path src = tempDir.createTempDir(); + try (Env env = Env.create().setSafeClose().setMaxReaders(1).open(src)) { + assertThatThrownBy(() -> env.copy(dest, MDB_CP_COMPACT)) + .isInstanceOf(InvalidCopyDestination.class); + } } @Test @@ -304,32 +286,78 @@ void copyFileBased() { final Path dest = tempDir.createTempFile(); assertThat(Files.exists(dest)).isFalse(); final Path src = tempDir.createTempFile(); - try (Env env = Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(src)) { + + // Create the source env and put an entry + try (Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(src)) { + final Dbi rwDb = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + rwDb.put(bb(1), bb(42)); + + env.copy(dest, MDB_CP_COMPACT); + } + + // Check the destination env and get the entry + try (Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(dest)) { + final Dbi dbi = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + try (Txn txn = env.txnRead()) { + final ByteBuffer byteBuffer = dbi.get(txn, bb(1)); + assertThat(byteBuffer).isNotNull(); + assertThat(byteBuffer.getInt()).isEqualTo(42); + } + } + assertThat(FileUtil.size(dest)).isGreaterThan(0L); + } + + @Test + void copyDirBased() { + final Path dest = tempDir.createTempDir(); + assertThat(isEmptyDir(dest)).isTrue(); + final Path src = tempDir.createTempDir(); + assertThat(isEmptyDir(src)).isTrue(); + // Create the source env and put an entry + try (Env env = Env.create().setSafeClose().setMaxReaders(1).open(src)) { + final Dbi rwDb = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + rwDb.put(bb(1), bb(42)); + env.copy(dest, MDB_CP_COMPACT); } + + // Check the destination env and get the entry + try (Env env = Env.create().setSafeClose().setMaxReaders(1).open(dest)) { + final Dbi dbi = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + try (Txn txn = env.txnRead()) { + final ByteBuffer byteBuffer = dbi.get(txn, bb(1)); + assertThat(byteBuffer).isNotNull(); + assertThat(byteBuffer.getInt()).isEqualTo(42); + } + } + assertThat(isEmptyDir(dest)).isFalse(); + assertThat(isEmptyDir(src)).isFalse(); assertThat(FileUtil.size(dest)).isGreaterThan(0L); } @Test - void copyFileRejectsExistingDestination() { - assertThatThrownBy( - () -> { - final Path dest = tempDir.createTempFile(); - Files.createFile(dest); - assertThat(Files.exists(dest)).isTrue(); - final Path src = tempDir.createTempFile(); - try (Env env = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(src)) { - env.copy(dest, MDB_CP_COMPACT); - } - }) - .isInstanceOf(InvalidCopyDestination.class); + void copyFileRejectsExistingDestination() throws IOException { + final Path dest = tempDir.createTempFile(); + Files.createFile(dest); + assertThat(Files.exists(dest)).isTrue(); + final Path src = tempDir.createTempFile(); + try (Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(src)) { + assertThatThrownBy(() -> env.copy(dest, MDB_CP_COMPACT)) + .isInstanceOf(InvalidCopyDestination.class); + } } @Test void createAsDirectory() { final Path dest = tempDir.createTempDir(); - final Env env = Env.create().setMaxReaders(1).open(dest); + final Env env = Env.create().setSafeClose().setMaxReaders(1).open(dest); assertThat(Files.isDirectory(dest)).isTrue(); env.sync(false); env.close(); @@ -342,6 +370,7 @@ void createAsFile() { final Path file = tempDir.createTempFile(); try (Env env = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -354,16 +383,31 @@ void createAsFile() { @Test void detectTransactionThreadViolation() { - assertThatThrownBy( - () -> { - final Path file = tempDir.createTempFile(); - try (Env env = - Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file)) { - env.txnRead(); - env.txnRead(); - } - }) - .isInstanceOf(BadReaderLockException.class); + final Path file = tempDir.createTempFile(); + try (Env env = + Env.create().setSafeClose().setMaxReaders(3).setEnvFlags(MDB_NOSUBDIR).open(file)) { + try (Txn ignored = env.txnRead()) { + // When NOT using MDB_NOTLS flag, you cannot open a second read txn on the same thread + assertThatThrownBy(env::txnRead).isInstanceOf(BadReaderLockException.class); + } + } + } + + @Test + void multipleReadTxnsOnSameThread() { + final Path file = tempDir.createTempFile(); + try (Env env = + Env.create() + .setSafeClose() + .setMaxReaders(3) + .setEnvFlags(MDB_NOSUBDIR, MDB_NOTLS) + .open(file)) { + try (Txn ignored1 = env.txnRead()) { + // MDB_NOTLS flag allows us to open multiple read txns on the same thread + //noinspection EmptyTryBlock + try (Txn ignored2 = env.txnRead()) {} + } + } } @Test @@ -371,6 +415,7 @@ void info() { final Path file = tempDir.createTempFile(); try (Env env = Env.create() + .setSafeClose() .setMaxReaders(4) .setMapSize(123_456) .setEnvFlags(MDB_NOSUBDIR) @@ -391,26 +436,23 @@ void info() { @Test void mapFull() { - assertThatThrownBy( - () -> { - final Path dir = tempDir.createTempDir(); - final byte[] k = new byte[500]; - final ByteBuffer key = allocateDirect(500); - final ByteBuffer val = allocateDirect(1_024); - final Random rnd = new Random(); - try (Env env = - Env.create() - .setMaxReaders(1) - .setMapSize(8, ByteUnit.MEBIBYTES) - .setMaxDbs(1) - .open(dir)) { - final Dbi db = - env.createDbi() - .setDbName(DB_1) - .withDefaultComparator() - .setDbiFlags(MDB_CREATE) - .open(); - //noinspection InfiniteLoopStatement // Needs infinite loop to fill the env + final Path dir = tempDir.createTempDir(); + final byte[] k = new byte[500]; + final ByteBuffer key = allocateDirect(500); + final ByteBuffer val = allocateDirect(1_024); + final Random rnd = new Random(); + try (Env env = + Env.create() + .setSafeClose() + .setMaxReaders(1) + .setMapSize(8, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .open(dir)) { + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + assertThatThrownBy( + () -> { + // Fill the env until MapFullException is thrown for (; ; ) { rnd.nextBytes(k); key.clear(); @@ -418,21 +460,21 @@ void mapFull() { val.clear(); db.put(key, val); } - } - }) - .isInstanceOf(MapFullException.class); + }) + .isInstanceOf(MapFullException.class); + } } @Test void readOnlySupported() { final Path dir = tempDir.createTempDir(); - try (Env rwEnv = Env.create().setMaxReaders(1).open(dir)) { + try (Env rwEnv = Env.create().setSafeClose().setMaxReaders(1).open(dir)) { final Dbi rwDb = rwEnv.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); rwDb.put(bb(1), bb(42)); } try (Env roEnv = - Env.create().setMaxReaders(1).setEnvFlags(MDB_RDONLY_ENV).open(dir)) { + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_RDONLY_ENV).open(dir)) { final Dbi roDb = roEnv .createDbi() @@ -454,7 +496,12 @@ void setMapSize() { final ByteBuffer val = allocateDirect(1_024); final Random rnd = new Random(); try (Env env = - Env.create().setMaxReaders(1).setMapSize(256, ByteUnit.KIBIBYTES).setMaxDbs(1).open(dir)) { + Env.create() + .setSafeClose() + .setMaxReaders(1) + .setMapSize(256, ByteUnit.KIBIBYTES) + .setMaxDbs(1) + .open(dir)) { final Dbi db = env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); @@ -473,17 +520,10 @@ void setMapSize() { } assertThat(mapFullExThrown).isTrue(); - assertThatThrownBy( - () -> { - env.setMapSize(-1, ByteUnit.KIBIBYTES); - }) + assertThatThrownBy(() -> env.setMapSize(-1, ByteUnit.KIBIBYTES)) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy( - () -> { - env.setMapSize(-1); - }) - .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> env.setMapSize(-1)).isInstanceOf(IllegalArgumentException.class); env.setMapSize(1024, ByteUnit.KIBIBYTES); @@ -512,7 +552,8 @@ void setMapSize() { @Test void stats() { final Path file = tempDir.createTempFile(); - try (Env env = Env.create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file)) { + try (Env env = + Env.create().setSafeClose().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR).open(file)) { final Stat stat = env.stat(); assertThat(stat).isNotNull(); assertThat(stat.branchPages).isEqualTo(0L); @@ -528,7 +569,8 @@ void stats() { @Test void testDefaultOpen() { final Path dir = tempDir.createTempDir(); - try (Env env = Env.create().setMapSize(10, ByteUnit.MEBIBYTES).open(dir)) { + try (Env env = + Env.create().setSafeClose().setMapSize(10, ByteUnit.MEBIBYTES).open(dir)) { final EnvInfo info = env.info(); assertThat(info.maxReaders).isEqualTo(MAX_READERS_DEFAULT); final Dbi db = @@ -540,7 +582,8 @@ void testDefaultOpen() { @Test void testDefaultOpenNoName1() { final Path dir = tempDir.createTempDir(); - try (Env env = Env.create().setMapSize(10, ByteUnit.MEBIBYTES).open(dir)) { + try (Env env = + Env.create().setSafeClose().setMapSize(10, ByteUnit.MEBIBYTES).open(dir)) { final EnvInfo info = env.info(); assertThat(info.maxReaders).isEqualTo(MAX_READERS_DEFAULT); final Dbi db = @@ -568,7 +611,8 @@ void testDefaultOpenNoName1() { @Test void testDefaultOpenNoName2() { final Path dir = tempDir.createTempDir(); - try (Env env = Env.create().setMapSize(10, ByteUnit.MEBIBYTES).open(dir)) { + try (Env env = + Env.create().setSafeClose().setMapSize(10, ByteUnit.MEBIBYTES).open(dir)) { final EnvInfo info = env.info(); assertThat(info.maxReaders).isEqualTo(MAX_READERS_DEFAULT); final Dbi db = @@ -591,8 +635,9 @@ void testDefaultOpenNoName2() { @Test void addEnvFlag() { final Path file = tempDir.createTempFile(); - try (Env env = + try (final Env env = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -611,6 +656,7 @@ void addEnvFlags() { final Path file = tempDir.createTempFile(); try (Env env = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -632,6 +678,7 @@ void addEnvFlags2() { final Path file = tempDir.createTempFile(); try (Env env = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -651,6 +698,7 @@ void setEnvFlags() { final Path file = tempDir.createTempFile(); try (Env env = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -673,6 +721,7 @@ void setEnvFlags2() { final Path dir = tempDir.createTempDir(); try (Env env = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -688,11 +737,13 @@ void setEnvFlags2() { @Test void setEnvFlags_null1() { final Path file = tempDir.createTempFile(); - // MDB_NOSUBDIR is cleared out so it will error as file is a file not a dir + // MDB_NOSUBDIR is cleared out, so it will error as file is a file not a dir Assertions.assertThatThrownBy( () -> { - try (Env env = + //noinspection EmptyTryBlock + try (final Env ignored = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -700,7 +751,8 @@ void setEnvFlags_null1() { .setEnvFlags((Collection) null) // Clears the flags .open(file)) {} }) - .isInstanceOf(LmdbNativeException.class); + .isInstanceOf(LmdbNativeException.class) + .hasMessageContaining("No such file or directory"); } @Test @@ -709,8 +761,10 @@ void setEnvFlags_null2() { // MDB_NOSUBDIR is cleared out so it will error as file is a file not a dir Assertions.assertThatThrownBy( () -> { - try (Env env = + //noinspection EmptyTryBlock + try (Env ignored = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -727,8 +781,10 @@ void setEnvFlags_null3() { // MDB_NOSUBDIR is cleared out so it will error as file is a file not a dir Assertions.assertThatThrownBy( () -> { - try (Env env = + //noinspection EmptyTryBlock + try (Env ignored = Env.create() + .setSafeClose() .setMapSize(1, ByteUnit.MEBIBYTES) .setMaxDbs(1) .setMaxReaders(1) @@ -738,4 +794,496 @@ void setEnvFlags_null3() { }) .isInstanceOf(LmdbNativeException.class); } + + @Test + void closeWithOpenReadTxn() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create() + .setSafeClose() + .setMapSize(1, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR) + .open(file); + + // Open but don't close + final Txn readTxn = env.txnWrite(); + + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + + readTxn.close(); + env.close(); + } + + @Test + void tryCloseWithOpenReadTxn() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create() + .setSafeClose() + .setMapSize(1, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR) + .open(file); + + // Open but don't close + final Txn readTxn = env.txnWrite(); + + assertThat(env.tryClose()).isFalse(); + readTxn.close(); + assertThat(env.tryClose()).isTrue(); + // already closed + assertThat(env.tryClose()).isFalse(); + } + + @Test + void immediateClose() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create() + .setSafeClose() + .setMapSize(1, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR) + .open(file); + + env.close(); + // no-op + env.close(); + } + + @Test + void immediateTryClose() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create() + .setSafeClose() + .setMapSize(1, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR) + .open(file); + + assertThat(env.tryClose()).isTrue(); + // already closed + assertThat(env.tryClose()).isFalse(); + } + + @Test + void closeWithOpenWriteTxn() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create() + .setSafeClose() + .setMapSize(1, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR) + .open(file); + + // Open but don't close + final Txn writeTxn = env.txnWrite(); + + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + + writeTxn.close(); + env.close(); + } + + @Test + void closeWithOpenRWCursor() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create() + .setMapSize(1, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR) + .setSafeClose(true) + .setSingleThreaded(true) + .open(file); + + final Dbi dbi = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + // Open but don't close + final Txn writeTxn = env.txnWrite(); + final Cursor cursor = dbi.openCursor(writeTxn); + // Close the txn but not the cursor + writeTxn.close(); + + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + + Assertions.assertThatThrownBy(cursor::close).isInstanceOf(Txn.NotReadyException.class); + + // can't close the env as we are unable to close the cursor + } + + @Test + void closeWithOpenROCursor() { + final Path file = tempDir.createTempFile(); + final Env env = + Env.create() + .setMapSize(1, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR) + .setSafeClose(true) + .setSingleThreaded(true) + .open(file); + + final Dbi dbi = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + // Open but don't close + final Txn readTxn = env.txnRead(); + final Cursor cursor = dbi.openCursor(readTxn); + + // Close the txn but not the cursor. LMDB will implicitly close the cursor as it is RO. + readTxn.close(); + + // Close env with no exception as it does not track RO cursors. + env.close(); + + Assertions.assertThatThrownBy(cursor::close).isInstanceOf(Env.AlreadyClosedException.class); + } + + /** + * Regression for the intermittent close-during-read SIGSEGV (lmdbjava#253 / lmdbjava#279). With + * safe close enabled, {@link Env#close()} must never unmap the memory map while another thread is + * still inside a live read transaction; instead it fails fast with {@link Env.EnvInUseException}. + * + *

Unlike the {@code RefCounter} unit tests, this exercises the real {@code Env}/{@code Txn} + * wiring against native LMDB: many threads hammer {@code txnRead()}/{@code Dbi.get} while another + * thread races {@link Env#close()}. On {@code master} (no safe close) this reliably crashes the + * JVM in {@code mdb_txn_renew0}; with safe close the close is rejected while reads are in flight, + * readers only ever observe {@link Env.AlreadyClosedException}, and the env closes cleanly once + * the readers stop. + */ + @Test + void closeDuringConcurrentReads_isRejectedWhileReadersLiveAndSurvives() throws Exception { + final Path dir = tempDir.createTempDir(); + final Env env = + Env.create().setSafeClose().setMaxReaders(64).setMaxDbs(1).open(dir); + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + for (int i = 0; i < 32; i++) { + db.put(bb(i), bb(i)); + } + + final int readerCount = 16; + final AtomicBoolean stop = new AtomicBoolean(false); + final AtomicLong reads = new AtomicLong(); + final List unexpected = new CopyOnWriteArrayList<>(); + final List readers = new ArrayList<>(readerCount); + + for (int i = 0; i < readerCount; i++) { + final int seed = i; + final Thread reader = + new Thread( + () -> { + int k = seed; + while (!stop.get()) { + try (Txn txn = env.txnRead()) { + db.get(txn, bb(k & 31)); + reads.incrementAndGet(); + k++; + } catch (final AlreadyClosedException expected) { + return; // benign: env is closing/closed + } catch (final Throwable t) { + unexpected.add(t); + return; + } + } + }, + "reader-" + seed); + reader.setDaemon(true); + reader.start(); + readers.add(reader); + } + + // A transaction held on this thread guarantees the count is non-zero, so the racing close() + // below deterministically fails fast rather than unmapping. The hammer threads meanwhile race + // real native txn begin/renew against that close(). + try (Txn ignoredHeldReader = env.txnRead()) { + Thread.sleep(100); // let the reader threads saturate the native read path + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + assertThat(env.tryClose()).isFalse(); + assertThat(env.isClosed()).isFalse(); // must NOT have unmapped with live readers + } + + // Stop the hammer threads and wait for every in-flight transaction to be released. + stop.set(true); + for (final Thread reader : readers) { + reader.join(5_000); + } + + // With no live transactions the env now closes cleanly. + env.close(); + + assertThat(reads.get()).isGreaterThan(0L); + assertThat(unexpected).isEmpty(); + assertThat(env.isClosed()).isTrue(); + } + + /** + * As {@link #closeDuringConcurrentReads_isRejectedWhileReadersLiveAndSurvives()} but the readers + * additionally open a {@link Cursor} on each transaction. Safe close newly tracks cursors as well + * as transactions, so this covers the cursor acquire/release wiring under a concurrent close + * race, which the existing single-threaded cursor test does not. + */ + @Test + void closeDuringConcurrentCursorReads_isRejectedWhileCursorsLiveAndSurvives() throws Exception { + final Path dir = tempDir.createTempDir(); + final Env env = + Env.create().setSafeClose().setMaxReaders(64).setMaxDbs(1).open(dir); + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + for (int i = 0; i < 32; i++) { + db.put(bb(i), bb(i)); + } + + final int readerCount = 16; + final AtomicBoolean stop = new AtomicBoolean(false); + final AtomicLong reads = new AtomicLong(); + final List unexpected = new CopyOnWriteArrayList<>(); + final List readers = new ArrayList<>(readerCount); + + for (int i = 0; i < readerCount; i++) { + final Thread reader = + new Thread( + () -> { + while (!stop.get()) { + try (Txn txn = env.txnRead(); + Cursor cursor = db.openCursor(txn)) { + cursor.first(); + reads.incrementAndGet(); + } catch (final AlreadyClosedException expected) { + return; // benign: env is closing/closed + } catch (final Throwable t) { + unexpected.add(t); + return; + } + } + }, + "cursor-reader-" + i); + reader.setDaemon(true); + reader.start(); + readers.add(reader); + } + + // A cursor held on this thread guarantees a non-zero count during the racing close(). + final Txn heldReader = env.txnRead(); + final Cursor heldCursor = db.openCursor(heldReader); + try { + Thread.sleep(100); + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + assertThat(env.tryClose()).isFalse(); + assertThat(env.isClosed()).isFalse(); + } finally { + heldCursor.close(); + heldReader.close(); + } + + stop.set(true); + for (final Thread reader : readers) { + reader.join(5_000); + } + + env.close(); + + assertThat(reads.get()).isGreaterThan(0L); + assertThat(unexpected).isEmpty(); + assertThat(env.isClosed()).isTrue(); + } + + @Test + void testEventualTryClose() throws InterruptedException { + final Path dir = tempDir.createTempDir(); + final Env env = + Env.create().setSafeClose().setMaxReaders(64).setMaxDbs(1).open(dir); + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + for (int i = 0; i < 32; i++) { + db.put(bb(i), bb(i)); + } + + final int readerCount = 16; + final AtomicLong reads = new AtomicLong(); + final LongAdder completedCount = new LongAdder(); + final List unexpected = new CopyOnWriteArrayList<>(); + final List readers = new ArrayList<>(readerCount); + + final Instant endTime = Instant.now().plusMillis(500); + + // Have 16 threads all hammer the env with reads until timeout + for (int i = 0; i < readerCount; i++) { + final Thread reader = + new Thread( + () -> { + while (Instant.now().isBefore(endTime)) { + try (Txn txn = env.txnRead(); + Cursor cursor = db.openCursor(txn)) { + cursor.first(); + reads.incrementAndGet(); + } catch (final AlreadyClosedException expected) { + return; // benign: env is closing/closed + } catch (final Throwable t) { + unexpected.add(t); + return; + } + } + completedCount.increment(); + }, + "cursor-reader-" + i); + reader.setDaemon(true); + reader.start(); + readers.add(reader); + } + + // Keep trying to close until we are able + boolean didClose = false; + while (!didClose) { + Thread.sleep(10); + didClose = env.tryClose(); + if (didClose) { + assertThat(completedCount).hasValue(readerCount); + } + } + + // readers should all have completed by now anyway + for (final Thread reader : readers) { + reader.join(5_000); + } + + assertThat(env.tryClose()).isFalse(); + assertThat(reads.get()).isGreaterThan(0L); + assertThat(unexpected).isEmpty(); + assertThat(completedCount).hasValue(readerCount); + assertThat(env.isClosed()).isTrue(); + } + + @ParameterizedTest + @CsvSource({ + "TRUE, TRUE", + "TRUE, FALSE", + "FALSE, TRUE", + "FALSE, FALSE", + "NO_ARG, NO_ARG", + "NOT_CALLED, NOT_CALLED" + }) + void singleThreaded(final BooleanArg safeClose, final BooleanArg singleThreaded) { + testEnvUse(safeClose, singleThreaded); + } + + @Test + void testToString() { + final Path dir = tempDir.createTempDir(); + try (Env env = + Env.create().setSafeClose().setMaxReaders(64).setMaxDbs(1).open(dir)) { + assertThat(env.toString()).doesNotStartWith("@"); + } + } + + private boolean isEmptyDir(final Path dir) { + try (Stream pathStream = Files.list(dir)) { + return !pathStream.findAny().isPresent(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void testEnvUse(final BooleanArg safeClose, final BooleanArg singleThreaded) { + final Path file = tempDir.createTempFile(); + + final Builder builder = + Env.create() + .setMapSize(1, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR); + + switch (safeClose) { + case TRUE: + case FALSE: + builder.setSafeClose(safeClose.getAsBoolean()); + // Handle no argument case + break; + case NO_ARG: + builder.setSafeClose(); + break; + case NOT_CALLED: + // Don't call anything + break; + } + + switch (singleThreaded) { + case TRUE: + case FALSE: + builder.setSingleThreaded(singleThreaded.getAsBoolean()); + // Handle no argument case + break; + case NO_ARG: + builder.setSingleThreaded(); + break; + case NOT_CALLED: + // Don't call anything + break; + } + + try (Env env = builder.open(file)) { + assertThat(env.isSafeClose()).isEqualTo(safeClose.getAsBoolean()); + assertThat(env.isSingleThreaded()).isEqualTo(singleThreaded.getAsBoolean()); + + final Dbi dbi = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + try (Txn txn = env.txnWrite()) { + for (int i = 0; i < 10; i++) { + dbi.put(txn, bb(i), bb(100 + i), MDB_APPENDDUP); + + if (safeClose.getAsBoolean()) { + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + } + } + txn.commit(); + } + + for (int i = 0; i < 5; i++) { + try (Txn txn = env.txnRead(); + Cursor cursor = dbi.openCursor(txn)) { + int j = 0; + while (cursor.next()) { + final KeyVal keyVal = cursor.keyVal(); + Assertions.assertThat(keyVal.key().getInt()).isEqualTo(j); + Assertions.assertThat(keyVal.val().getInt()).isEqualTo(100 + j); + if (safeClose.getAsBoolean()) { + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + } + j++; + } + } + } + } + } + + private enum BooleanArg { + TRUE(true), + FALSE(false), + NO_ARG(true), + NOT_CALLED(false); // Both safeClose and singleThreaded default to false if not set + + private final boolean isTrue; + + BooleanArg(final boolean isTrue) { + this.isTrue = isTrue; + } + + boolean getAsBoolean() { + return isTrue; + } + } } diff --git a/src/test/java/org/lmdbjava/GarbageCollectionTest.java b/src/test/java/org/lmdbjava/GarbageCollectionTest.java index 4aa1245f..ffd8f56d 100644 --- a/src/test/java/org/lmdbjava/GarbageCollectionTest.java +++ b/src/test/java/org/lmdbjava/GarbageCollectionTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.nio.ByteBuffer.allocateDirect; @@ -37,7 +36,8 @@ class GarbageCollectionTest { void buffersNotGarbageCollectedTest() { try (final TempDir tempDir = new TempDir()) { final Path dir = tempDir.createTempDir(); - try (Env env = Env.create().setMapSize(2_085_760_999).setMaxDbs(1).open(dir)) { + try (Env env = + Env.create().setSafeClose().setMapSize(2_085_760_999).setMaxDbs(1).open(dir)) { final Dbi db = env.createDbi() .setDbName(DB_NAME) diff --git a/src/test/java/org/lmdbjava/KeyValTest.java b/src/test/java/org/lmdbjava/KeyValTest.java new file mode 100644 index 00000000..6dd3b3f9 --- /dev/null +++ b/src/test/java/org/lmdbjava/KeyValTest.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.ByteBuffer; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class KeyValTest { + + @Test + void testClose() { + //noinspection unchecked + final BufferProxy mockBufferProxy = + (BufferProxy) Mockito.mock(BufferProxy.class); + final KeyVal keyVal = new KeyVal<>(mockBufferProxy); + + keyVal.close(); + + Mockito.verify(mockBufferProxy, Mockito.times(2)).deallocate(Mockito.any()); + + // Already closed, a no-op + keyVal.close(); + + Mockito.verify(mockBufferProxy, Mockito.times(2)).deallocate(Mockito.any()); + } +} diff --git a/src/test/java/org/lmdbjava/RefCounterBenchmark.java b/src/test/java/org/lmdbjava/RefCounterBenchmark.java new file mode 100644 index 00000000..ca58efae --- /dev/null +++ b/src/test/java/org/lmdbjava/RefCounterBenchmark.java @@ -0,0 +1,150 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import org.jspecify.annotations.NonNull; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +public class RefCounterBenchmark { + + private static final int ITERATIONS = 2; + private static final int WARMUP = 2; + private static final int FORK = 2; + + @Benchmark + @BenchmarkMode(Mode.Throughput) + @Measurement(iterations = ITERATIONS) + @Warmup(iterations = WARMUP) + @Fork(value = FORK, warmups = WARMUP) + @Threads(Threads.MAX) + public void allThreads(final MultiThreadPlan plan, final Blackhole blackhole) { + final RefCounter.RefCounterReleaser releaser = plan.refCounter.acquire(); + blackhole.consume(releaser); + releaser.release(); + } + + @Benchmark + @BenchmarkMode(Mode.Throughput) + @Measurement(iterations = ITERATIONS) + @Warmup(iterations = WARMUP) + @Fork(value = FORK, warmups = WARMUP) + @Threads(8) + public void eightThreads(final MultiThreadPlan plan, final Blackhole blackhole) { + final RefCounter.RefCounterReleaser releaser = plan.refCounter.acquire(); + blackhole.consume(releaser); + releaser.release(); + } + + @Benchmark + @BenchmarkMode(Mode.Throughput) + @Measurement(iterations = ITERATIONS) + @Warmup(iterations = WARMUP) + @Fork(value = FORK, warmups = WARMUP) + @Threads(4) + public void fourThreads(final MultiThreadPlan plan, final Blackhole blackhole) { + final RefCounter.RefCounterReleaser releaser = plan.refCounter.acquire(); + blackhole.consume(releaser); + releaser.release(); + } + + @Benchmark + @BenchmarkMode(Mode.Throughput) + @Measurement(iterations = ITERATIONS) + @Warmup(iterations = WARMUP) + @Fork(value = FORK, warmups = WARMUP) + @Threads(2) + public void twoThreads(final MultiThreadPlan plan, final Blackhole blackhole) { + final RefCounter.RefCounterReleaser releaser = plan.refCounter.acquire(); + blackhole.consume(releaser); + releaser.release(); + } + + @Benchmark + @BenchmarkMode(Mode.Throughput) + @Measurement(iterations = ITERATIONS) + @Warmup(iterations = WARMUP) + @Fork(value = FORK, warmups = WARMUP) + @Threads(1) + public void oneThread(final SingleThreadPlan plan, final Blackhole blackhole) { + final RefCounter.RefCounterReleaser releaser = plan.refCounter.acquire(); + blackhole.consume(releaser); + releaser.release(); + } + + private static @NonNull RefCounter getRefCounter(final String refCounterName) { + final RefCounter refCounter; + switch (refCounterName) { + case "striped": + refCounter = new StripedRefCounter(); + break; + case "simple": + refCounter = new SimpleRefCounter(); + break; + case "synchronised": + refCounter = new SynchronisedRefCounter(); + break; + case "no-op": + refCounter = new NoOpRefCounter(); + break; + case "single": + refCounter = new SingleThreadedRefCounter(); + break; + default: + throw new IllegalArgumentException("Unknown name '" + refCounterName + "'"); + } + return refCounter; + } + + @State(Scope.Benchmark) + public static class MultiThreadPlan { + + private RefCounter refCounter; + + @Param({"striped", "simple", "synchronised", "no-op"}) + public String refCounterName; + + @Setup(Level.Invocation) + public void setUp() { + this.refCounter = getRefCounter(refCounterName); + } + } + + @State(Scope.Benchmark) + public static class SingleThreadPlan { + + private RefCounter refCounter; + + @Param({"striped", "simple", "synchronised", "no-op", "single"}) + public String refCounterName; + + @Setup(Level.Invocation) + public void setUp() { + this.refCounter = getRefCounter(refCounterName); + } + } +} diff --git a/src/test/java/org/lmdbjava/RefCounterTest.java b/src/test/java/org/lmdbjava/RefCounterTest.java new file mode 100644 index 00000000..12ddceca --- /dev/null +++ b/src/test/java/org/lmdbjava/RefCounterTest.java @@ -0,0 +1,876 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.text.NumberFormat; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Queue; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class RefCounterTest { + private static final int PROCESSOR_COUNT = Runtime.getRuntime().availableProcessors(); + private final int iterations = 20_000_000; + private final int processorCount = PROCESSOR_COUNT; + + /** + * @return A {@link Stream} of all {@link RefCounter}s for {@link ParameterizedTest}s. + */ + private static Stream allRefCounterProvider() { + return Stream.concat( + multiThreadedRefCounterProvider(), + Stream.of(new SingleThreadedRefCounter(), new NoOpRefCounter()) + .map(RefCounterTest::createArguments)); + } + + /** + * @return A {@link Stream} of {@link RefCounter}s that support multithreaded use for {@link + * ParameterizedTest}s. + */ + private static Stream multiThreadedRefCounterProvider() { + return Stream.of(new StripedRefCounter(), new SimpleRefCounter(), new SynchronisedRefCounter()) + .map(RefCounterTest::createArguments); + } + + private static Arguments createArguments(final RefCounter refCounter) { + return Arguments.argumentSet(refCounter.getClass().getSimpleName(), refCounter); + } + + @Disabled // Manual performance test + @Test + public void perfTest() { + // Do multiple rounds to let it warm up + for (int i = 1; i <= 3; i++) { + final int round = i; + // Run tests with all available processors + System.out.println( + "Multi-threaded (" + + processorCount + + " threads) tests ---------------------------------"); + + System.out.println("Round: " + round + " " + StripedRefCounter.class.getSimpleName()); + IntStream.of(1, 16, 32, 64, 128, 256) + .forEach(stripes -> runPerfTest(stripes, new StripedRefCounter(stripes))); + + final StripedRefCounter defaultStripedRefCounter = new StripedRefCounter(); + runPerfTest(defaultStripedRefCounter.getStripeCount(), defaultStripedRefCounter); + + System.out.println("Round: " + round + " " + SimpleRefCounter.class.getSimpleName()); + runPerfTest(0, new SimpleRefCounter()); + + System.out.println("Round: " + round + " " + SynchronisedRefCounter.class.getSimpleName()); + runPerfTest(0, new SynchronisedRefCounter()); + + System.out.println("Round: " + round + " " + NoOpRefCounter.class.getSimpleName()); + runPerfTest(0, new NoOpRefCounter()); + + // Run tests with set numbers of worker threads + IntStream.of(32, 16, 8, 4, 2) + .forEach( + threads -> { + System.out.println( + "Multi-threaded (" + + threads + + " threads) tests ---------------------------------"); + + System.out.println( + "Round: " + round + " " + StripedRefCounter.class.getSimpleName()); + IntStream.of(1, 16, 32, 64, 128, 256) + .forEach( + stripes -> runPerfTest(stripes, threads, new StripedRefCounter(stripes))); + + System.out.println( + "Round: " + round + " " + SimpleRefCounter.class.getSimpleName()); + runPerfTest(0, threads, new SimpleRefCounter()); + + System.out.println( + "Round: " + round + " " + SynchronisedRefCounter.class.getSimpleName()); + runPerfTest(0, threads, new SynchronisedRefCounter()); + + System.out.println("Round: " + round + " " + NoOpRefCounter.class.getSimpleName()); + runPerfTest(0, threads, new NoOpRefCounter()); + }); + + System.out.println("Single-threaded tests ---------------------------------"); + + System.out.println("Round: " + round + " " + StripedRefCounter.class.getSimpleName()); + runPerfTest(1, 1, new StripedRefCounter()); + + System.out.println("Round: " + round + " " + SimpleRefCounter.class.getSimpleName()); + runPerfTest(0, 1, new SimpleRefCounter()); + + System.out.println("Round: " + round + " " + SynchronisedRefCounter.class.getSimpleName()); + runPerfTest(0, 1, new SynchronisedRefCounter()); + + System.out.println("Round: " + round + " " + NoOpRefCounter.class.getSimpleName()); + runPerfTest(0, 1, new NoOpRefCounter()); + + System.out.println("Round: " + round + " " + SingleThreadedRefCounter.class.getSimpleName()); + runPerfTest(0, 1, new SingleThreadedRefCounter()); + + System.out.println( + "--------------------------------------------------------------------------------"); + System.out.println(); + } + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void testRefCounters_close(final RefCounter refCounter) { + // Acquire twice + final RefCounter.RefCounterReleaser releaser1 = refCounter.acquire(); + assertRefCount(refCounter, 1); + final RefCounter.RefCounterReleaser releaser2 = refCounter.acquire(); + assertRefCount(refCounter, 2); + + final AtomicInteger onCloseCallCount = new AtomicInteger(); + + if (!(refCounter instanceof NoOpRefCounter)) { + // Close() not called as ref count is two. + + Assertions.assertThatThrownBy( + () -> { + refCounter.close(onCloseCallCount::incrementAndGet); + }) + .isInstanceOf(Env.EnvInUseException.class) + .hasMessageContaining(" 2 "); + } + assertThat(onCloseCallCount).hasValue(0); + + // Release 1st releaser + releaser1.release(); + assertRefCount(refCounter, 1); + + if (!(refCounter instanceof NoOpRefCounter)) { + // Close() not called as ref count is one. + Assertions.assertThatThrownBy( + () -> { + refCounter.close(onCloseCallCount::incrementAndGet); + }) + .isInstanceOf(Env.EnvInUseException.class) + .hasMessageContaining(" 1 "); + } + assertThat(onCloseCallCount).hasValue(0); + + // Release 2nd releaser + releaser2.release(); + assertThat(refCounter.getCount()).isEqualTo(0); + + // no-op if already released + releaser1.release(); + assertThat(refCounter.getCount()).isEqualTo(0); + + // no-op if already released + releaser2.release(); + assertThat(refCounter.getCount()).isEqualTo(0); + + // onClose is called now + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount).hasValue(1); + + // no-op as onClose already called + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount).hasValue(1); + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void testRefCounters_tryClose(final RefCounter refCounter) { + // Acquire twice + final RefCounter.RefCounterReleaser releaser1 = refCounter.acquire(); + assertRefCount(refCounter, 1); + final RefCounter.RefCounterReleaser releaser2 = refCounter.acquire(); + assertRefCount(refCounter, 2); + + final AtomicInteger onCloseCallCount = new AtomicInteger(); + + if (!(refCounter instanceof NoOpRefCounter)) { + // Close() not called as ref count is two. + assertThat(refCounter.tryClose(onCloseCallCount::incrementAndGet)).isFalse(); + } + assertThat(onCloseCallCount).hasValue(0); + + // Release 1st releaser + releaser1.release(); + assertRefCount(refCounter, 1); + + if (!(refCounter instanceof NoOpRefCounter)) { + // Close() not called as ref count is one. + assertThat(refCounter.tryClose(onCloseCallCount::incrementAndGet)).isFalse(); + } + assertThat(onCloseCallCount).hasValue(0); + + // Release 2nd releaser + releaser2.release(); + assertThat(refCounter.getCount()).isEqualTo(0); + + // no-op if already released + releaser1.release(); + assertThat(refCounter.getCount()).isEqualTo(0); + + // no-op if already released + releaser2.release(); + assertThat(refCounter.getCount()).isEqualTo(0); + + // onClose is called now + assertThat(refCounter.tryClose(onCloseCallCount::incrementAndGet)).isTrue(); + assertThat(onCloseCallCount).hasValue(1); + + // no-op as onClose already called + assertThat(refCounter.tryClose(onCloseCallCount::incrementAndGet)).isFalse(); + assertThat(onCloseCallCount).hasValue(1); + } + + @ParameterizedTest + @MethodSource("multiThreadedRefCounterProvider") + void multipleThreads(final RefCounter refCounter) { + final int iterations = 1000; + final AtomicInteger[] callCounts = new AtomicInteger[processorCount]; + for (int i = 0; i < processorCount; i++) { + callCounts[i] = new AtomicInteger(); + } + final CountDownLatch countDownLatch = new CountDownLatch(processorCount); + //noinspection resource ExecutorService does not implement AutoCloseable in Java8 + final ExecutorService executorService = Executors.newFixedThreadPool(processorCount); + try { + + final CompletableFuture[] futures = + IntStream.range(0, processorCount) + .boxed() + .map( + i -> + CompletableFuture.runAsync( + () -> { + TestUtils.countDownThenAwait(countDownLatch); + for (int j = 0; j < iterations; j++) { + final RefCounter.RefCounterReleaser releaser = refCounter.acquire(); + callCounts[i].getAndIncrement(); + releaser.release(); + } + }, + executorService)) + .toArray(CompletableFuture[]::new); + + CompletableFuture.allOf(futures).join(); + + assertThat(refCounter.getCount()).isEqualTo(0); + + for (AtomicInteger callCount : callCounts) { + assertThat(callCount).hasValue(iterations); + } + } finally { + // ExecutorService does not implement AutoCloseable in Java8 + executorService.shutdown(); + } + } + + @ParameterizedTest + @MethodSource("multiThreadedRefCounterProvider") + void multipleThreads_delayedRelease(final RefCounter refCounter) { + final int iterations = 1000; + final AtomicInteger[] callCounts; + final Queue releasers; + + //noinspection resource ExecutorService does not implement AutoCloseable in Java8 + final ExecutorService executorService = Executors.newFixedThreadPool(processorCount); + final ExecutorService executorService2 = Executors.newFixedThreadPool(processorCount); + + try { + callCounts = new AtomicInteger[processorCount]; + for (int i = 0; i < processorCount; i++) { + callCounts[i] = new AtomicInteger(); + } + final CountDownLatch countDownLatch = new CountDownLatch(processorCount); + + releasers = new ConcurrentLinkedQueue<>(); + final Queue> futures = new ConcurrentLinkedQueue<>(); + + IntStream.range(0, processorCount) + .boxed() + .map( + i -> + CompletableFuture.runAsync( + () -> { + TestUtils.countDownThenAwait(countDownLatch); + for (int j = 0; j < iterations; j++) { + final RefCounter.RefCounterReleaser releaser = refCounter.acquire(); + releasers.add(releaser); + callCounts[i].getAndIncrement(); + futures.add( + CompletableFuture.runAsync( + () -> { + final long count = refCounter.getCount(); + assertThat(count).isNotEqualTo(0); + }, + executorService2)); + } + }, + executorService)) + .forEach(futures::add); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } finally { + executorService2.shutdown(); + executorService.shutdown(); + } + + assertRefCount(refCounter, processorCount * iterations); + + for (AtomicInteger callCount : callCounts) { + assertThat(callCount).hasValue(iterations); + } + + releasers.forEach(RefCounter.RefCounterReleaser::release); + + assertThat(refCounter.getCount()).isEqualTo(0); + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void testImmediateClose(final RefCounter refCounter) { + assertThat(refCounter.isClosed()).isEqualTo(false); + final AtomicInteger onCloseCallCount = new AtomicInteger(); + + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount).hasValue(1); + assertThat(refCounter.isClosed()).isEqualTo(true); + + assertThatThrownBy(refCounter::checkNotClosed).isInstanceOf(Env.AlreadyClosedException.class); + + // Check again as idempotent + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount).hasValue(1); + assertThat(refCounter.isClosed()).isEqualTo(true); + + assertThatThrownBy(refCounter::checkNotClosed).isInstanceOf(Env.AlreadyClosedException.class); + } + + /** + * Lots of threads all doing acquire/release in a loop, then the main thread tries to call + * refCounter.close(...), which will throw an {@link org.lmdbjava.Env.EnvInUseException}. The main + * thread then makes all worker threads stop their looping and calls refCounter.close(...) again, + * successfully this time. + */ + @ParameterizedTest + @MethodSource("multiThreadedRefCounterProvider") + void testBehaviour(final RefCounter refCounter) throws InterruptedException { + final Random random = new Random(); + final int threadCount = this.processorCount - 1; + //noinspection resource ExecutorService does not implement AutoCloseable in Java8 + final ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + try { + final int rounds = 5; + final int iterations = 10_000_000; + final AtomicReference mockEnv = new AtomicReference<>(); + + for (int k = 0; k < rounds; k++) { + + // Reset the env + mockEnv.set(new Object()); + final RefCounter roundRefCounter = createNewRefCounter(refCounter); + final CountDownLatch startLatch = new CountDownLatch(threadCount); + final CompletableFuture[] futures = new CompletableFuture[threadCount]; + final AtomicLong[] counts = new AtomicLong[threadCount]; + for (int i = 0; i < threadCount; i++) { + counts[i] = new AtomicLong(); + } + + final AtomicBoolean abortThreads = new AtomicBoolean(false); + + for (int i = 0; i < threadCount; i++) { + final int threadIdx = i; + futures[threadIdx] = + CompletableFuture.runAsync( + () -> { + // Wait for all threads to be ready + TestUtils.countDownThenAwait(startLatch); + for (int j = 0; j < iterations; j++) { + if (abortThreads.get()) { + break; + } + + final RefCounter.RefCounterReleaser releaser; + try { + releaser = roundRefCounter.acquire(); + counts[threadIdx].incrementAndGet(); + } catch (Env.AlreadyClosedException e) { + break; + } + try { + // Make the work between acquire and release take some time + TestUtils.sleep(random.nextInt(5)); + // env is null after closure + assertThat(mockEnv.get()).isNotNull(); + // Objects.requireNonNull(mockEnv.get(), "Attempt to + // use a null env"); + } finally { + releaser.release(); + } + } + }, + executorService); + } + + // Wait for all threads to start using the ref counter + startLatch.await(); + + // Give the other threads a chance to get underway + TestUtils.sleep(200 + random.nextInt(200)); + final AtomicBoolean didClose = new AtomicBoolean(false); + final AtomicInteger onCloseCallCount = new AtomicInteger(); + while (!didClose.get()) { + try { + assertThat(mockEnv.get()).isNotNull(); + roundRefCounter.close( + () -> { + onCloseCallCount.incrementAndGet(); + // Imitate closing the env + mockEnv.set(null); + didClose.set(true); + }); + if (didClose.get()) { + // We closed, so env should be null + assertThat(mockEnv).hasNullValue(); + } + } catch (Env.EnvInUseException e) { + // Failed to close as there are un-released items, so env still alive + assertThat(mockEnv.get()).isNotNull(); + // Now poke all the treads to make them cleanly finish what they are doing so we + // can try close() again + abortThreads.set(true); + TestUtils.sleep(500); + } + } + + // Wait for all workers to finish + CompletableFuture.allOf(futures).join(); + + // Make sure the mock env is all closed down + assertThat(mockEnv).hasNullValue(); + assertThat(roundRefCounter.isClosed()).isEqualTo(true); + assertThat(roundRefCounter.getCount()).isZero(); + assertThatThrownBy(roundRefCounter::acquire).isInstanceOf(Env.AlreadyClosedException.class); + assertThat(onCloseCallCount).hasValue(1); + } + } finally { + // ExecutorService does not implement AutoCloseable in Java8 + executorService.shutdown(); + } + } + + /** + * Ensure we can call getCount when multiple threads are all calling acquire/release in a loop. + */ + @ParameterizedTest + @MethodSource("multiThreadedRefCounterProvider") + void testGetCount(final RefCounter refCounter) throws InterruptedException { + final Random random = new Random(); + final int threadCount = this.processorCount - 1; + //noinspection resource ExecutorService does not implement AutoCloseable in Java8 + final ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + try { + final int rounds = 5; + final int iterations = 10_000_000; + final AtomicReference mockEnv = new AtomicReference<>(); + final AtomicBoolean abortThreads = new AtomicBoolean(false); + + for (int k = 0; k < rounds; k++) { + // Reset the env + mockEnv.set(new Object()); + abortThreads.set(false); + final RefCounter roundRefCounter = createNewRefCounter(refCounter); + final CountDownLatch startLatch = new CountDownLatch(threadCount); + final CompletableFuture[] futures = new CompletableFuture[threadCount]; + final long[] counts = new long[threadCount]; + + for (int i = 0; i < threadCount; i++) { + final int threadIdx = i; + futures[threadIdx] = + CompletableFuture.runAsync( + () -> { + // Wait for all threads to be ready + TestUtils.countDownThenAwait(startLatch); + + for (int j = 0; j < iterations; j++) { + if (abortThreads.get()) { + break; + } + final RefCounter.RefCounterReleaser releaser; + try { + releaser = roundRefCounter.acquire(); + counts[threadIdx]++; + } catch (Env.AlreadyClosedException e) { + break; + } + try { + // Make the work between acquire and release take some time + TestUtils.sleep(random.nextInt(5)); + // env is null after closure + Objects.requireNonNull(mockEnv.get(), "Attempt to use a null env"); + } finally { + releaser.release(); + } + // Random sleep after releasing so there is a time when the thread + // is not using the 'env' + TestUtils.sleep(5 + random.nextInt(5)); + } + }, + executorService); + } + + // Wait for all threads to start using the ref counter + startLatch.await(); + + // Give the other threads a chance to get underway + TestUtils.sleep(100 + random.nextInt(200)); + + for (int i = 0; i < 10; i++) { + try { + // Makes sure we can acquire the ref counter count + roundRefCounter.getCount(); + } catch (Env.EnvInUseException e) { + TestUtils.sleep(100 + random.nextInt(200)); + } + } + abortThreads.set(true); + // Wait for all workers to finish + CompletableFuture.allOf(futures).join(); + + if (roundRefCounter.getCount() != 0) { + throw new IllegalStateException("Ref count is " + roundRefCounter.getCount()); + } + } + } finally { + // ExecutorService does not implement AutoCloseable in Java8 + executorService.shutdown(); + } + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void immediateClose(final RefCounter refCounter) { + final AtomicInteger onCloseCallCount = new AtomicInteger(); + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(refCounter.getCount()).isZero(); + assertThat(refCounter.isClosed()).isTrue(); + assertThatThrownBy(refCounter::checkNotClosed).isInstanceOf(Env.AlreadyClosedException.class); + assertThat(onCloseCallCount.get()).isEqualTo(1); + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void acquireAfterClose(final RefCounter refCounter) { + final AtomicInteger onCloseCallCount = new AtomicInteger(); + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount.get()).isEqualTo(1); + + if (!(refCounter instanceof NoOpRefCounter)) { + assertThatThrownBy(refCounter::acquire).isInstanceOf(Env.AlreadyClosedException.class); + } + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void releaseAfterClose(final RefCounter refCounter) { + final AtomicInteger onCloseCallCount = new AtomicInteger(); + final RefCounter.RefCounterReleaser releaser = refCounter.acquire(); + // Need to release to allow the close + releaser.release(); + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount.get()).isEqualTo(1); + + // This is a no-op as already released + releaser.release(); + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void countAfterClose(final RefCounter refCounter) { + final AtomicInteger onCloseCallCount = new AtomicInteger(); + assertThat(refCounter.getCount()).isZero(); + final RefCounter.RefCounterReleaser releaser = refCounter.acquire(); + if (!(refCounter instanceof NoOpRefCounter)) { + assertThat(refCounter.getCount()).isEqualTo(1); + } + // Need to release to allow the close + releaser.release(); + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount.get()).isEqualTo(1); + + assertThat(refCounter.getCount()).isZero(); + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void use_null(final RefCounter refCounter) { + // A no-op + refCounter.use(null); + + final AtomicInteger onCloseCallCount = new AtomicInteger(); + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount.get()).isEqualTo(1); + + // A no-op + refCounter.use(null); + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void use(final RefCounter refCounter) { + final AtomicInteger onCloseCallCount = new AtomicInteger(); + final AtomicInteger useCallCount = new AtomicInteger(); + refCounter.use( + () -> { + useCallCount.incrementAndGet(); + if (!(refCounter instanceof NoOpRefCounter)) { + assertThatThrownBy(() -> refCounter.close(onCloseCallCount::incrementAndGet)) + .isInstanceOf(Env.EnvInUseException.class); + } + }); + + refCounter.use( + () -> { + useCallCount.incrementAndGet(); + if (!(refCounter instanceof NoOpRefCounter)) { + assertThatThrownBy(() -> refCounter.close(onCloseCallCount::incrementAndGet)) + .isInstanceOf(Env.EnvInUseException.class); + } + }); + + assertThat(useCallCount.get()).isEqualTo(2); + assertThat(onCloseCallCount.get()).isEqualTo(0); + + assertThat(refCounter.getCount()).isEqualTo(0); + + refCounter.close(onCloseCallCount::incrementAndGet); + assertThat(onCloseCallCount.get()).isEqualTo(1); + + // use after close + if (!(refCounter instanceof NoOpRefCounter)) { + assertThatThrownBy(() -> refCounter.use(useCallCount::incrementAndGet)) + .isInstanceOf(Env.AlreadyClosedException.class); + } + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void failedOnCloseDoesNotCloseOrCorruptCounter() { + final StripedRefCounter refCounter = new StripedRefCounter(); + + assertThatThrownBy( + () -> + refCounter.close( + () -> { + throw new RuntimeException("boom"); + })) + .isInstanceOf(RuntimeException.class); + + assertThat(refCounter.isClosed()).isFalse(); + + final RefCounter.RefCounterReleaser releaser = refCounter.acquire(); + assertThat(refCounter.getCount()).isEqualTo(1); + releaser.release(); + assertThat(refCounter.getCount()).isEqualTo(0); + } + + @ParameterizedTest + @MethodSource("allRefCounterProvider") + void concurrentCloseIsIdempotent() { + final StripedRefCounter refCounter = new StripedRefCounter(); + final AtomicInteger onCloseCallCount = new AtomicInteger(); + + final CountDownLatch startLatch = new CountDownLatch(2); + + final CompletableFuture first = + CompletableFuture.runAsync( + () -> { + TestUtils.countDownThenAwait(startLatch); + refCounter.close(onCloseCallCount::incrementAndGet); + }); + final CompletableFuture second = + CompletableFuture.runAsync( + () -> { + TestUtils.countDownThenAwait(startLatch); + refCounter.close(onCloseCallCount::incrementAndGet); + }); + + CompletableFuture.allOf(first, second).join(); + + assertThat(onCloseCallCount).hasValue(1); + assertThat(refCounter.isClosed()).isTrue(); + assertThatThrownBy(refCounter::acquire).isInstanceOf(Env.AlreadyClosedException.class); + } + + @Test + public void noOpRefCounter() { + // Do multiple rounds to let it warm up + for (int i = 0; i < 20; i++) { + doNoOpRefCounter(); + } + } + + private void doNoOpRefCounter() { + final AtomicReference startTime = new AtomicReference<>(null); + final CompletableFuture[] futures = new CompletableFuture[processorCount]; + final NoOpRefCounter refCounter = new NoOpRefCounter(); + final CountDownLatch startLatch = new CountDownLatch(processorCount); + final ExecutorService executorService = Executors.newFixedThreadPool(processorCount); + try { + final int iterationsPerThread = iterations / processorCount; + for (int i = 0; i < processorCount; i++) { + futures[i] = + CompletableFuture.runAsync( + () -> { + // Wait for all threads to be ready + TestUtils.countDownThenAwait(startLatch); + + // Capture the start time + startTime.updateAndGet( + currVal -> { + if (currVal == null) { + return Instant.now(); + } else { + return currVal; + } + }); + + for (int j = 0; j < iterationsPerThread; j++) { + // Just acquire then release + final RefCounter.RefCounterReleaser releaser = refCounter.acquire(); + releaser.release(); + } + }, + executorService); + } + CompletableFuture.allOf(futures).join(); + + // final Duration duration = Duration.between(startTime.get(), Instant.now()); + // final long iterationsPerSec = Math.round((double) iterations / duration.toMillis() * + // 1000); + // System.out.println( + // "All Finished" + // + ", threads: " + // + threadCount + // + ", iterationsPerThread: " + // + iterationsPerThread + // + ", duration: " + // + duration + // + ", iterationsPerSec: " + // + NumberFormat.getInstance().format(iterationsPerSec)); + } finally { + // ExecutorService does not implement AutoCloseable in Java8 + executorService.shutdown(); + } + } + + private void runPerfTest(int stripes, final RefCounter refCounter) { + runPerfTest(stripes, processorCount, refCounter); + } + + private void runPerfTest(int stripes, final int threadCount, final RefCounter refCounter) { + final AtomicReference startTime = new AtomicReference<>(null); + final CompletableFuture[] futures = new CompletableFuture[threadCount]; + final CountDownLatch startLatch = new CountDownLatch(threadCount); + final ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + try { + final int iterationsPerThread = iterations / threadCount; + for (int i = 0; i < threadCount; i++) { + futures[i] = + CompletableFuture.runAsync( + () -> { + // Wait for all threads to be ready + TestUtils.countDownThenAwait(startLatch); + // Capture the start time + startTime.updateAndGet( + currVal -> { + if (currVal == null) { + return Instant.now(); + } else { + return currVal; + } + }); + + for (int j = 0; j < iterationsPerThread; j++) { + final RefCounter.RefCounterReleaser releaser = refCounter.acquire(); + releaser.release(); + } + }, + executorService); + } + CompletableFuture.allOf(futures).join(); + + if (refCounter.getCount() != 0) { + throw new IllegalStateException("Ref count is " + refCounter.getCount()); + } + + final Duration duration = Duration.between(startTime.get(), Instant.now()); + final long iterationsPerSec = Math.round((double) iterations / duration.toMillis() * 1000); + + System.out.println( + "All Finished" + + ", stripes: " + + stripes + + ", threads: " + + threadCount + + ", iterationsPerThread: " + + iterationsPerThread + + ", duration: " + + duration + + ", iterationsPerSec: " + + NumberFormat.getInstance().format(iterationsPerSec)); + } finally { + // ExecutorService does not implement AutoCloseable in Java8 + executorService.shutdown(); + } + } + + private static RefCounter createNewRefCounter(RefCounter refCounter) { + // Assumes all RefCounters have a no-arg constructor + try { + return refCounter.getClass().getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void assertRefCount(final RefCounter refCounter, final int expectedCount) { + // NoOpRefCounter does no reference counting, so we can't assert the count + if (!(refCounter instanceof NoOpRefCounter)) { + assertThat(refCounter.getCount()).isEqualTo(expectedCount); + } + } +} diff --git a/src/test/java/org/lmdbjava/StripedRefCounterTest.java b/src/test/java/org/lmdbjava/StripedRefCounterTest.java new file mode 100644 index 00000000..3cc9752a --- /dev/null +++ b/src/test/java/org/lmdbjava/StripedRefCounterTest.java @@ -0,0 +1,85 @@ +/* + * Copyright © 2016-2026 The LmdbJava Open Source Project + * + * Licensed 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.lmdbjava; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class StripedRefCounterTest { + + @Test + void lowestPowerOfTwoGreaterThanOrEqualTo() { + // Test powers of two + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(1)).isEqualTo(1); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(2)).isEqualTo(2); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(4)).isEqualTo(4); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(8)).isEqualTo(8); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(16)).isEqualTo(16); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(1024)).isEqualTo(1024); + + // Test non-powers of two + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(3)).isEqualTo(4); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(5)).isEqualTo(8); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(7)).isEqualTo(8); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(15)).isEqualTo(16); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(24)).isEqualTo(32); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(100)).isEqualTo(128); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(1000)).isEqualTo(1024); + + // Test edge cases + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(536870912)) + .isEqualTo(536870912); + assertThat(StripedRefCounter.lowestPowerOfTwoGreaterThanOrEqualTo(536870913)) + .isEqualTo(1073741824); + } + + @Test + void getStripeCount() { + final StripedRefCounter stripedRefCounter = new StripedRefCounter(); + assertThat(stripedRefCounter.getStripeCount()).isGreaterThan(1); + } + + @Test + void getStripeCount2() { + final StripedRefCounter stripedRefCounter = new StripedRefCounter(16); + assertThat(stripedRefCounter.getStripeCount()).isEqualTo(16); + } + + @Test + void getStripeCount3() { + final StripedRefCounter stripedRefCounter = new StripedRefCounter(15); + assertThat(stripedRefCounter.getStripeCount()).isEqualTo(16); + } + + @Test + void getStripeCount4() { + assertThatThrownBy(() -> new StripedRefCounter(99999999)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void getStripeCount5() { + assertThatThrownBy(() -> new StripedRefCounter(0)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void getStripeCount6() { + assertThatThrownBy(() -> new StripedRefCounter(-1)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/org/lmdbjava/TargetNameTest.java b/src/test/java/org/lmdbjava/TargetNameTest.java index 6c0a8f44..a3d2d7c9 100644 --- a/src/test/java/org/lmdbjava/TargetNameTest.java +++ b/src/test/java/org/lmdbjava/TargetNameTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static org.assertj.core.api.Assertions.assertThat; @@ -21,6 +20,7 @@ import static org.lmdbjava.TargetName.resolveFilename; import static org.lmdbjava.TestUtils.invokePrivateConstructor; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; /** Test {@link TargetName}. */ @@ -66,6 +66,36 @@ void externalTakesPriority() { assertThat(isExternal("/lm.so")).isTrue(); } + @Test + void resolveExtension_null() { + assertThat(TargetName.resolveExtension(null)).isEqualTo("so"); + } + + @Test + void resolveExtension_unknown() { + assertThat(TargetName.resolveExtension("foo")).isEqualTo("so"); + } + + @Test + void badArch() { + Assertions.assertThatThrownBy( + () -> { + TargetName.resolveFilename(NONE, NONE, "badArch", "Linux"); + }) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("os.arch"); + } + + @Test + void badOs() { + Assertions.assertThatThrownBy( + () -> { + TargetName.resolveFilename(NONE, NONE, "arch", "badOs"); + }) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("os.name"); + } + private void embed(final String lib, final String arch, final String os) { assertThat(resolveFilename(NONE, NONE, arch, os)).isEqualTo("org/lmdbjava/native/" + lib); assertThat(isExternal(NONE)).isFalse(); diff --git a/src/test/java/org/lmdbjava/TestUtils.java b/src/test/java/org/lmdbjava/TestUtils.java index a15dc6b2..c89c3b9a 100644 --- a/src/test/java/org/lmdbjava/TestUtils.java +++ b/src/test/java/org/lmdbjava/TestUtils.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,8 +26,10 @@ import java.nio.charset.StandardCharsets; import java.util.Comparator; import java.util.Objects; +import java.util.concurrent.CountDownLatch; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.StreamSupport; import org.agrona.MutableDirectBuffer; import org.agrona.concurrent.UnsafeBuffer; @@ -84,6 +86,12 @@ static ByteBuffer bbNative(final long value) { return bb; } + static int getInt(final ByteBuffer bb) { + final int val = bb.getInt(); + bb.rewind(); + return val; + } + static int getNativeInt(final ByteBuffer bb) { final int val = bb.order(ByteOrder.nativeOrder()).getInt(); bb.rewind(); @@ -202,4 +210,36 @@ static ComparatorResult compare(final Comparator comparator, final T o1, final int result = comparator.compare(o1, o2); return ComparatorResult.get(result); } + + public static void countDownThenAwait(final CountDownLatch latch) { + Objects.requireNonNull(latch); + latch.countDown(); + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + public static void sleep(final int millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + public static int getEntryCount(final Dbi dbi, final Env env) { + try (final Txn readTxn = env.txnRead()) { + return getEntryCount(dbi, readTxn); + } + } + + public static int getEntryCount(final Dbi dbi, final Txn txn) { + try (CursorIterable cursorIterable = dbi.iterate(txn)) { + return (int) StreamSupport.stream(cursorIterable.spliterator(), false).count(); + } + } } diff --git a/src/test/java/org/lmdbjava/TutorialTest.java b/src/test/java/org/lmdbjava/TutorialTest.java index c2271363..ac853de7 100644 --- a/src/test/java/org/lmdbjava/TutorialTest.java +++ b/src/test/java/org/lmdbjava/TutorialTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.nio.charset.StandardCharsets.UTF_8; @@ -89,6 +88,9 @@ void tutorial1() { .setMapSize(10_485_760) // LMDB also needs to know how many DBs (Dbi) we want to store in this Env. .setMaxDbs(1) + // Add additional checks to ensure the env is not close while in use. Adds + // some performance overhead + .setSafeClose() // Now let's open the Env. The same path can be concurrently opened and // used in different processes, but do not open the same path twice in // the same process at the same time. @@ -231,38 +233,37 @@ void tutorial3() { try (Txn txn = env.txnWrite()) { // A cursor always belongs to a particular Dbi. - final Cursor c = db.openCursor(txn); - - // We can put via a Cursor. Note we're adding keys in a strange order, - // as we want to show you that LMDB returns them in sorted order. - key.put("zzz".getBytes(UTF_8)).flip(); - val.put("lmdb".getBytes(UTF_8)).flip(); - c.put(key, val); - key.clear(); - key.put("aaa".getBytes(UTF_8)).flip(); - c.put(key, val); - key.clear(); - key.put("ccc".getBytes(UTF_8)).flip(); - c.put(key, val); + try (Cursor c = db.openCursor(txn)) { - // We can read from the Cursor by key. - c.get(key, MDB_SET); - assertThat(UTF_8.decode(c.key()).toString()).isEqualTo("ccc"); + // We can put via a Cursor. Note we're adding keys in a strange order, + // as we want to show you that LMDB returns them in sorted order. + key.put("zzz".getBytes(UTF_8)).flip(); + val.put("lmdb".getBytes(UTF_8)).flip(); + c.put(key, val); + key.clear(); + key.put("aaa".getBytes(UTF_8)).flip(); + c.put(key, val); + key.clear(); + key.put("ccc".getBytes(UTF_8)).flip(); + c.put(key, val); - // Let's see that LMDB provides the keys in appropriate order.... - c.seek(MDB_FIRST); - assertThat(UTF_8.decode(c.key()).toString()).isEqualTo("aaa"); + // We can read from the Cursor by key. + c.get(key, MDB_SET); + assertThat(UTF_8.decode(c.key()).toString()).isEqualTo("ccc"); - c.seek(MDB_LAST); - assertThat(UTF_8.decode(c.key()).toString()).isEqualTo("zzz"); + // Let's see that LMDB provides the keys in appropriate order.... + c.seek(MDB_FIRST); + assertThat(UTF_8.decode(c.key()).toString()).isEqualTo("aaa"); - c.seek(MDB_PREV); - assertThat(UTF_8.decode(c.key()).toString()).isEqualTo("ccc"); + c.seek(MDB_LAST); + assertThat(UTF_8.decode(c.key()).toString()).isEqualTo("zzz"); - // Cursors can also delete the current key. - c.delete(); + c.seek(MDB_PREV); + assertThat(UTF_8.decode(c.key()).toString()).isEqualTo("ccc"); - c.close(); + // Cursors can also delete the current key. + c.delete(); + } txn.commit(); } @@ -286,6 +287,7 @@ void tutorial3() { tx2.renew(); c.seek(MDB_LAST); + c.close(); tx2.close(); env.close(); } @@ -369,34 +371,33 @@ void tutorial5() { final ByteBuffer val = ByteBuffer.allocateDirect(env.getMaxKeySize()); try (Txn txn = env.txnWrite()) { - final Cursor c = db.openCursor(txn); - - // Store one key, but many values, and in non-natural order. - key.put("key".getBytes(UTF_8)).flip(); - val.put("xxx".getBytes(UTF_8)).flip(); - c.put(key, val); - val.clear(); - val.put("kkk".getBytes(UTF_8)).flip(); - c.put(key, val); - val.clear(); - val.put("lll".getBytes(UTF_8)).flip(); - c.put(key, val); + try (Cursor c = db.openCursor(txn)) { - // Cursor can tell us how many values the current key has. - final long count = c.count(); - assertThat(count).isEqualTo(3L); + // Store one key, but many values, and in non-natural order. + key.put("key".getBytes(UTF_8)).flip(); + val.put("xxx".getBytes(UTF_8)).flip(); + c.put(key, val); + val.clear(); + val.put("kkk".getBytes(UTF_8)).flip(); + c.put(key, val); + val.clear(); + val.put("lll".getBytes(UTF_8)).flip(); + c.put(key, val); - // Let's position the Cursor. Note sorting still works. - c.seek(MDB_FIRST); - assertThat(UTF_8.decode(c.val()).toString()).isEqualTo("kkk"); + // Cursor can tell us how many values the current key has. + final long count = c.count(); + assertThat(count).isEqualTo(3L); - c.seek(MDB_LAST); - assertThat(UTF_8.decode(c.val()).toString()).isEqualTo("xxx"); + // Let's position the Cursor. Note sorting still works. + c.seek(MDB_FIRST); + assertThat(UTF_8.decode(c.val()).toString()).isEqualTo("kkk"); - c.seek(MDB_PREV); - assertThat(UTF_8.decode(c.val()).toString()).isEqualTo("lll"); + c.seek(MDB_LAST); + assertThat(UTF_8.decode(c.val()).toString()).isEqualTo("xxx"); - c.close(); + c.seek(MDB_PREV); + assertThat(UTF_8.decode(c.val()).toString()).isEqualTo("lll"); + } txn.commit(); } @@ -415,6 +416,7 @@ void tutorial6() { Env.create(PROXY_OPTIMAL) .setMapSize(10, ByteUnit.MEBIBYTES) .setMaxDbs(Verifier.DBI_COUNT) + .setSafeClose() .open(dir); // Create a Verifier (it's a Callable for those needing full control). @@ -435,7 +437,11 @@ void tutorial7() { // There's also a PROXY_SAFE if you want to stop ByteBuffer's Unsafe use. // Aside from that and a different type argument, it's the same as usual... final Env env = - Env.create(PROXY_DB).setMapSize(10, ByteUnit.MEBIBYTES).setMaxDbs(1).open(dir); + Env.create(PROXY_DB) + .setMapSize(10, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setSafeClose() + .open(dir); final Dbi db = env.createDbi().setDbName(DB_NAME).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); @@ -616,6 +622,11 @@ void tutorial9() { // or reverse ordered keys, using Env.DISABLE_CHECKS_PROP etc), but you now // know enough to tackle the JavaDocs with confidence. Have fun! private Env createSimpleEnv(final Path path) { - return Env.create().setMapSize(10, ByteUnit.MEBIBYTES).setMaxDbs(1).setMaxReaders(1).open(path); + return Env.create() + .setMapSize(10, ByteUnit.MEBIBYTES) + .setMaxDbs(1) + .setMaxReaders(1) + .setSafeClose() + .open(path); } } diff --git a/src/test/java/org/lmdbjava/TxnDeprecatedTest.java b/src/test/java/org/lmdbjava/TxnDeprecatedTest.java index 387e9fef..f3ef7ce1 100644 --- a/src/test/java/org/lmdbjava/TxnDeprecatedTest.java +++ b/src/test/java/org/lmdbjava/TxnDeprecatedTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static org.assertj.core.api.Assertions.assertThat; @@ -26,6 +25,7 @@ import java.nio.file.Path; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.lmdbjava.Env.AlreadyClosedException; import org.lmdbjava.Txn.IncompatibleParent; @@ -50,6 +50,7 @@ void beforeEach() { file = tempDir.createTempFile(); env = create() + .setSafeClose() .setMapSize(256, ByteUnit.KIBIBYTES) .setMaxReaders(1) .setMaxDbs(2) @@ -81,6 +82,7 @@ public void txParent2() { } } + @Disabled // We shouldn't be trying to close the env with open txns/cursors @Test void txParentDeniedIfEnvClosed() { assertThatThrownBy( diff --git a/src/test/java/org/lmdbjava/TxnTest.java b/src/test/java/org/lmdbjava/TxnTest.java index 7210b613..d0f6f18c 100644 --- a/src/test/java/org/lmdbjava/TxnTest.java +++ b/src/test/java/org/lmdbjava/TxnTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static java.nio.ByteBuffer.allocateDirect; @@ -40,6 +39,7 @@ import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.lmdbjava.Dbi.BadValueSizeException; import org.lmdbjava.Env.AlreadyClosedException; @@ -65,6 +65,7 @@ void beforeEach() { file = tempDir.createTempFile(); env = create() + .setSafeClose() .setMapSize(256, ByteUnit.KIBIBYTES) .setMaxReaders(1) .setMaxDbs(2) @@ -134,8 +135,15 @@ void rangeSearch() { void readOnlyTxnAllowedInReadOnlyEnv() { env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); try (Env roEnv = - create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR, MDB_RDONLY_ENV).open(file)) { - assertThat(roEnv.txnRead()).isNotNull(); + create() + .setSafeClose() + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR, MDB_RDONLY_ENV) + .open(file)) { + try (Txn readTxn = roEnv.txnRead()) { + assertThat(readTxn).isNotNull(); + assertReadOnly(readTxn); + } } } @@ -150,7 +158,11 @@ void readWriteTxnDeniedInReadOnlyEnv() { .open(); env.close(); try (Env roEnv = - create().setMaxReaders(1).setEnvFlags(MDB_NOSUBDIR, MDB_RDONLY_ENV).open(file)) { + create() + .setSafeClose() + .setMaxReaders(1) + .setEnvFlags(MDB_NOSUBDIR, MDB_RDONLY_ENV) + .open(file)) { roEnv.txnWrite(); // error } }) @@ -169,6 +181,16 @@ void testCheckNotCommitted() { .isInstanceOf(NotReadyException.class); } + @Test + void testDoubleCommit() { + try (Txn txn = env.txnRead()) { + txn.commit(); + assertState(txn, DONE); + assertThatThrownBy(txn::commit).isInstanceOf(NotReadyException.class); + assertState(txn, DONE); + } + } + @Test void testCheckReadOnly() { assertThatThrownBy( @@ -211,105 +233,125 @@ void testGetId() { assertThat(txId1.get()).isNotEqualTo(txId2.get()); } + @Test + void txIdDeniedIfEnvClosed() { + final Txn txnRead = env.txnRead(); + txnRead.close(); + env.close(); + assertThatThrownBy(txnRead::getId).isInstanceOf(AlreadyClosedException.class); + } + @Test void txCanCommitThenCloseWithoutError() { try (Txn txn = env.txnRead()) { - assertThat(txn.getState()).isEqualTo(READY); + assertState(txn, READY); txn.commit(); - assertThat(txn.getState()).isEqualTo(DONE); + assertState(txn, DONE); + } + } + + @Test + void txAbortThenClose() { + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + try (Txn txn = env.txnWrite()) { + assertState(txn, READY); + db.put(txn, bb(1), bb(2)); + assertThat(db.get(txn, bb(1))).isEqualTo(bb(2)); + + // Change rolled back + txn.abort(); + } + + try (Txn txn = env.txnRead()) { + assertThat(db.get(txn, bb(1))).isNull(); + } + } + + @Test + void txCloseWithoutAbort() { + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + try (Txn txn = env.txnWrite()) { + assertState(txn, READY); + db.put(txn, bb(1), bb(2)); + assertThat(db.get(txn, bb(1))).isEqualTo(bb(2)); + + // Change rolled back by implicit abort on close + } + + try (Txn txn = env.txnRead()) { + assertThat(db.get(txn, bb(1))).isNull(); } } @Test void txCannotAbortIfAlreadyCommitted() { - assertThatThrownBy( - () -> { - try (Txn txn = env.txnRead()) { - assertThat(txn.getState()).isEqualTo(READY); - txn.commit(); - assertThat(txn.getState()).isEqualTo(DONE); - txn.abort(); - } - }) - .isInstanceOf(NotReadyException.class); + + try (Txn txn = env.txnRead()) { + assertState(txn, READY); + txn.commit(); + assertState(txn, DONE); + assertThatThrownBy(txn::abort).isInstanceOf(NotReadyException.class); + } } @Test void txCannotCommitTwice() { - assertThatThrownBy( - () -> { - try (Txn txn = env.txnRead()) { - txn.commit(); - txn.commit(); // error - } - }) - .isInstanceOf(NotReadyException.class); + try (Txn txn = env.txnRead()) { + txn.commit(); + assertThatThrownBy(txn::commit).isInstanceOf(NotReadyException.class); + } } @Test void txConstructionDeniedIfEnvClosed() { - assertThatThrownBy( - () -> { - env.close(); - env.txnRead(); - }) - .isInstanceOf(AlreadyClosedException.class); + env.close(); + assertThatThrownBy(env::txnRead).isInstanceOf(AlreadyClosedException.class); } @Test void txRenewDeniedIfEnvClosed() { - assertThatThrownBy( - () -> { - final Txn txnRead = env.txnRead(); - txnRead.close(); - env.close(); - txnRead.renew(); - }) - .isInstanceOf(AlreadyClosedException.class); + final Txn txnRead = env.txnRead(); + txnRead.close(); + env.close(); + assertThatThrownBy(txnRead::renew).isInstanceOf(AlreadyClosedException.class); } @Test void txCloseDeniedIfEnvClosed() { - assertThatThrownBy( - () -> { - final Txn txnRead = env.txnRead(); - env.close(); - txnRead.close(); - }) - .isInstanceOf(AlreadyClosedException.class); + final Txn txnRead = env.txnRead(); + // We can't test closing the env with the txn open as the env will prevent it + txnRead.close(); + env.close(); + assertThatThrownBy(txnRead::close).isInstanceOf(AlreadyClosedException.class); } @Test void txCommitDeniedIfEnvClosed() { - assertThatThrownBy( - () -> { - final Txn txnRead = env.txnRead(); - env.close(); - txnRead.commit(); - }) - .isInstanceOf(AlreadyClosedException.class); + final Txn txnRead = env.txnRead(); + // We can't test closing the env with the txn open as the env will prevent it + txnRead.close(); + env.close(); + assertThatThrownBy(txnRead::commit).isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns @Test void txAbortDeniedIfEnvClosed() { - assertThatThrownBy( - () -> { - final Txn txnRead = env.txnRead(); - env.close(); - txnRead.abort(); - }) - .isInstanceOf(AlreadyClosedException.class); + final Txn txnRead = env.txnRead(); + env.close(); + assertThatThrownBy(txnRead::abort).isInstanceOf(AlreadyClosedException.class); } + @Disabled // We shouldn't be trying to close the env with open txns @Test void txResetDeniedIfEnvClosed() { - assertThatThrownBy( - () -> { - final Txn txnRead = env.txnRead(); - env.close(); - txnRead.reset(); - }) - .isInstanceOf(AlreadyClosedException.class); + final Txn txnRead = env.txnRead(); + env.close(); + assertThatThrownBy(txnRead::reset).isInstanceOf(AlreadyClosedException.class); } @Test @@ -341,6 +383,99 @@ public void txParent3() { } } + @Test + public void txParent4() { + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + try (Txn txRoot = env.txnWrite()) { + assertThat(txRoot.getParent()).isNull(); + + // Put using the parent txn + db.put(txRoot, bb(1), bb(10)); + assertThat(db.get(txRoot, bb(1))).isEqualTo(bb(10)); + + try (Txn txChild = env.txn(txRoot)) { + assertThat(txChild.getParent()).isEqualTo(txRoot); + + assertThat(db.get(txChild, bb(1))).isEqualTo(bb(10)); + + // Put using the child txn + db.put(txChild, bb(2), bb(20)); + assertThat(db.get(txChild, bb(2))).isEqualTo(bb(20)); + + // Rollback the child txn's change + txChild.abort(); + } + + // Root's change still there + assertThat(db.get(txRoot, bb(1))).isEqualTo(bb(10)); + // Child's change was rolled back + assertThat(db.get(txRoot, bb(2))).isNull(); + + // Put using the parent txn again + db.put(txRoot, bb(3), bb(30)); + assertThat(db.get(txRoot, bb(3))).isEqualTo(bb(30)); + + // Commit the parent txn's change without the child's changes + txRoot.commit(); + } + + // Open a new txn to assert the entry + try (Txn txn = env.txnRead()) { + assertThat(db.get(txn, bb(1))).isEqualTo(bb(10)); + assertThat(db.get(txn, bb(2))).isNull(); + assertThat(db.get(txn, bb(3))).isEqualTo(bb(30)); + } + } + + @Test + public void txParent5() { + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + + try (Txn txRoot = env.txnWrite()) { + assertThat(txRoot.getParent()).isNull(); + + // Put using the parent txn + db.put(txRoot, bb(1), bb(10)); + assertThat(db.get(txRoot, bb(1))).isEqualTo(bb(10)); + + try (Txn txChild = env.txn(txRoot)) { + assertThat(txChild.getParent()).isEqualTo(txRoot); + + assertThat(db.get(txChild, bb(1))).isEqualTo(bb(10)); + + // Put using the child txn + db.put(txChild, bb(2), bb(20)); + assertThat(db.get(txChild, bb(2))).isEqualTo(bb(20)); + + // Commit the child txn's change + txChild.commit(); + } + + // Root's change still there + assertThat(db.get(txRoot, bb(1))).isEqualTo(bb(10)); + // Child's change was rolled back + assertThat(db.get(txRoot, bb(2))).isEqualTo(bb(20)); + + // Put using the parent txn again + db.put(txRoot, bb(3), bb(30)); + assertThat(db.get(txRoot, bb(3))).isEqualTo(bb(30)); + + // Roll back everything, including the changes committed in the child txn + txRoot.abort(); + } + + // Open a new txn to assert the entry + try (Txn txn = env.txnRead()) { + assertThat(db.get(txn, bb(1))).isNull(); + assertThat(db.get(txn, bb(2))).isNull(); + assertThat(db.get(txn, bb(3))).isNull(); + } + } + + @Disabled // We shouldn't be trying to close the env with open txns @Test void txParentDeniedIfEnvClosed() { assertThatThrownBy( @@ -380,18 +515,16 @@ void txParentRWChildROIncompatible() { void txReadOnly() { try (Txn txn = env.txnRead()) { assertThat(txn.getParent()).isNull(); - assertThat(txn.getState()).isEqualTo(READY); - assertThat(txn.isReadOnly()).isTrue(); - txn.checkReady(); - txn.checkReadOnly(); + assertState(txn, READY); + assertReadOnly(txn); txn.reset(); - assertThat(txn.getState()).isEqualTo(RESET); + assertState(txn, RESET); txn.renew(); - assertThat(txn.getState()).isEqualTo(READY); + assertState(txn, READY); txn.commit(); - assertThat(txn.getState()).isEqualTo(DONE); + assertState(txn, DONE); txn.close(); - assertThat(txn.getState()).isEqualTo(RELEASED); + assertState(txn, RELEASED); } } @@ -399,14 +532,12 @@ void txReadOnly() { void txReadWrite() { final Txn txn = env.txnWrite(); assertThat(txn.getParent()).isNull(); - assertThat(txn.getState()).isEqualTo(READY); - assertThat(txn.isReadOnly()).isFalse(); - txn.checkReady(); - txn.checkWritesAllowed(); + assertState(txn, READY); + assertWritable(txn); txn.commit(); - assertThat(txn.getState()).isEqualTo(DONE); + assertState(txn, DONE); txn.close(); - assertThat(txn.getState()).isEqualTo(RELEASED); + assertState(txn, RELEASED); } @Test @@ -462,4 +593,30 @@ void zeroByteKeysRejected() { }) .isInstanceOf(BadValueSizeException.class); } + + private void assertState(final Txn txn, final Txn.State expectedState) { + assertThat(txn.getState()).isEqualTo(expectedState); + if (expectedState == READY) { + assertThat(txn.isReady()).isTrue(); + txn.checkReady(); + } else { + assertThat(txn.isReady()).isFalse(); + assertThatThrownBy(txn::checkReady).isInstanceOf(NotReadyException.class); + } + } + + private void assertReadOnly(final Txn txn) { + assertThat(txn.isReadOnly()).isTrue(); + assertThat(txn.isWritable()).isFalse(); + txn.checkReadOnly(); + assertThatThrownBy(txn::checkWritesAllowed).isInstanceOf(ReadWriteRequiredException.class); + } + + private void assertWritable(final Txn txn) { + assertThat(txn.isReadOnly()).isFalse(); + assertThat(txn.isWritable()).isTrue(); + assertThatThrownBy(txn::checkReadOnly).isInstanceOf(ReadOnlyRequiredException.class); + // Should not throw in a writable state + txn.checkWritesAllowed(); + } } diff --git a/src/test/java/org/lmdbjava/VerifierTest.java b/src/test/java/org/lmdbjava/VerifierTest.java index ee396084..6364ea9c 100644 --- a/src/test/java/org/lmdbjava/VerifierTest.java +++ b/src/test/java/org/lmdbjava/VerifierTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2016-2025 The LmdbJava Open Source Project + * Copyright © 2016-2026 The LmdbJava Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.lmdbjava; import static org.assertj.core.api.Assertions.assertThat; @@ -35,6 +34,7 @@ void verification() { final Path file = tempDir.createTempFile(); try (Env env = create() + .setSafeClose() .setMaxReaders(1) .setMaxDbs(Verifier.DBI_COUNT) .setMapSize(10, ByteUnit.MEBIBYTES)