Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 230 additions & 8 deletions src/main/java/org/lmdbjava/Env.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
import java.util.stream.Collectors;
import jnr.ffi.Pointer;
import jnr.ffi.byref.IntByReference;
Expand Down Expand Up @@ -67,7 +70,26 @@ public final class Env<T> implements AutoCloseable {
*/
public static final boolean SHOULD_CHECK = !getBoolean(DISABLE_CHECKS_PROP);

private boolean closed;
// volatile: close() may run on a different thread than the readers that call checkNotClosed().
// Without it there is no happens-before between the write here and those reads, so a reader could
// indefinitely observe a stale false (the JIT may even hoist the check out of a hot loop) and
// proceed into a native call on a freed env. This does NOT make close() atomic w.r.t. an
// in-flight
// txnRead()/txnWrite() (the check-then-mdb_txn_begin window in those methods remains); it removes
// the pure visibility bug and turns more of those races into a clean AlreadyClosedException
// rather
// than a JVM crash. See close() for the full lifecycle contract.
private volatile boolean closed;

// Opt-in "safe close" (see Builder#setSafeClose). When enabled, live transactions are tracked so
// close(Duration) can drain in-flight readers before unmapping instead of risking the JVM crash
// documented on close(). All three fields are inert unless safeClose is true, so the default hot
// path is byte-for-byte unchanged: liveTxns is null and never touched, and the tracking branches
// are guarded by the final safeClose flag.
private final boolean safeClose;
private volatile boolean closing;
private final AtomicInteger liveTxns;

private final int maxKeySize;
private final boolean noSubDir;
private final BufferProxy<T> proxy;
Expand All @@ -82,7 +104,8 @@ private Env(
final boolean readOnly,
final boolean noSubDir,
final Path path,
final EnvFlagSet envFlagSet) {
final EnvFlagSet envFlagSet,
final boolean safeClose) {
this.proxy = proxy;
this.readOnly = readOnly;
this.noSubDir = noSubDir;
Expand All @@ -91,6 +114,8 @@ private Env(
this.maxKeySize = LIB.mdb_env_get_maxkeysize(ptr);
this.path = path;
this.envFlagSet = envFlagSet;
this.safeClose = safeClose;
this.liveTxns = safeClose ? new AtomicInteger() : null;
}

/**
Expand Down Expand Up @@ -131,6 +156,31 @@ public static Env<ByteBuffer> open(final File path, final int size, final EnvFla
* Close the handle.
*
* <p>Will silently return if already closed or never opened.
*
* <p><strong>Thread-safety / lifecycle contract.</strong> This method is <em>not</em>
* synchronized and (consistent with the package-level policy that LmdbJava provides no
* concurrency guarantees) it does not coordinate with other threads. Before and during this call
* the caller MUST ensure that:
*
* <ul>
* <li>every {@link Txn}, {@link Cursor} and {@link Dbi} obtained from this environment has
* already been closed; and
* <li>no other thread is executing <em>any</em> operation on this environment or on a handle
* derived from it — including {@link #txnRead()} / {@link #txnWrite()} and reads such as
* {@code Dbi.get}.
* </ul>
*
* <p>Violating this contract is <strong>undefined behaviour that can crash the whole JVM</strong>
* ({@code SIGSEGV} on Linux/macOS, {@code EXCEPTION_ACCESS_VIOLATION 0xC0000005} on Windows); it
* does <em>not</em> 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}.
*
* <p>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.
*/
@Override
public void close() {
Expand All @@ -141,6 +191,89 @@ public void close() {
LIB.mdb_env_close(ptr);
}

/**
* Close the handle, first draining any in-flight transactions ("safe close").
*
* <p>This is the opt-in, thread-safe counterpart to {@link #close()} and requires the environment
* to have been built with {@link Builder#setSafeClose(boolean) setSafeClose(true)} (otherwise an
* {@link IllegalStateException} is thrown). Unlike {@link #close()} it will <em>not</em> unmap
* the memory map while a transaction is still live, so it does not risk the JVM crash described
* on {@link #close()}.
*
* <p>On entry it stops new {@link #txnRead()} / {@link #txnWrite()} calls (they throw {@link
* AlreadyClosedException}), then waits up to {@code timeout} for every transaction obtained from
* this environment to be closed before calling the native close. If the timeout elapses with
* transactions still open it throws {@link CloseTimeoutException} and does <em>not</em> unmap
* (leaked or stuck transactions are a caller bug; forcing the unmap would reintroduce the crash).
*
* <p>Idempotent: returns immediately if the environment is already closed. This method only
* tracks transactions created <em>after</em> the environment was opened with safe close enabled;
* it cannot police direct native misuse or handles shared across processes.
*
* @param timeout maximum time to wait for in-flight transactions to drain
* @throws IllegalStateException if this environment was not built with safe close enabled
* @throws CloseTimeoutException if transactions remain open after {@code timeout}
*/
public void close(final Duration timeout) {
requireNonNull(timeout);
if (!safeClose) {
throw new IllegalStateException(
"close(Duration) requires Env.Builder.setSafeClose(true); use close() otherwise");
}
if (closed) {
return;
}
// Publish "closing" before reading the live count. A reader increments liveTxns before reading
// closing (see beforeTxnBeginIfTracked); with both being volatile/atomic this handshake ensures
// that if the drain below observes zero live txns, any concurrent reader will observe closing
// and back out before it starts a native transaction. So the map is never unmapped under a live
// or about-to-start read.
closing = true;
final long deadlineNanos = System.nanoTime() + timeout.toNanos();
while (liveTxns.get() > 0) {
if (System.nanoTime() - deadlineNanos >= 0L) {
throw new CloseTimeoutException(liveTxns.get());
}
LockSupport.parkNanos(500_000L); // 0.5 ms; close is rare, so a short poll is fine
}
closed = true;
LIB.mdb_env_close(ptr);
}

/**
* Registers a soon-to-begin transaction when safe close is enabled, and returns whether tracking
* happened so the caller can balance the count if {@code mdb_txn_begin} then fails. Must be
* called before the native transaction start so a concurrent {@link #close(Duration)} cannot
* unmap between the check and the begin.
*/
private boolean beforeTxnBeginIfTracked() {
if (!safeClose) {
return false;
}
liveTxns.incrementAndGet();
if (closing || closed) {
liveTxns.decrementAndGet();
throw new AlreadyClosedException();
}
return true;
}

/** Balances {@link #beforeTxnBeginIfTracked()} when a tracked transaction is closed. */
void afterTxnClosed() {
if (safeClose) {
liveTxns.decrementAndGet();
}
}

/**
* Indicates whether this environment was built with the opt-in safe close enabled.
*
* @return true if {@link Builder#setSafeClose(boolean)} was set
*/
public boolean isSafeClose() {
return safeClose;
}

/**
* Copies an LMDB environment to the specified destination path.
*
Expand Down Expand Up @@ -537,7 +670,15 @@ public void sync(final boolean force) {
@Deprecated
public Txn<T> txn(final Txn<T> parent, final TxnFlags... flags) {
checkNotClosed();
return new Txn<>(this, parent, proxy, TxnFlagSet.of(flags));
final boolean tracked = beforeTxnBeginIfTracked();
try {
return new Txn<>(this, parent, proxy, TxnFlagSet.of(flags));
} catch (final RuntimeException e) {
if (tracked) {
liveTxns.decrementAndGet();
}
throw e;
}
}

/**
Expand All @@ -548,7 +689,15 @@ public Txn<T> txn(final Txn<T> parent, final TxnFlags... flags) {
*/
public Txn<T> txn(final Txn<T> parent) {
checkNotClosed();
return new Txn<>(this, parent, proxy, TxnFlagSet.EMPTY);
final boolean tracked = beforeTxnBeginIfTracked();
try {
return new Txn<>(this, parent, proxy, TxnFlagSet.EMPTY);
} catch (final RuntimeException e) {
if (tracked) {
liveTxns.decrementAndGet();
}
throw e;
}
}

/**
Expand All @@ -562,27 +711,61 @@ public Txn<T> txn(final Txn<T> parent) {
*/
public Txn<T> txn(final Txn<T> parent, final TxnFlagSet flags) {
checkNotClosed();
return new Txn<>(this, parent, proxy, flags);
final boolean tracked = beforeTxnBeginIfTracked();
try {
return new Txn<>(this, parent, proxy, flags);
} catch (final RuntimeException e) {
if (tracked) {
liveTxns.decrementAndGet();
}
throw e;
}
}

/**
* Obtain a read-only transaction.
*
* <p>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<T> txnRead() {
checkNotClosed();
return new Txn<>(this, null, proxy, TxnFlags.MDB_RDONLY_TXN);
final boolean tracked = beforeTxnBeginIfTracked();
try {
return new Txn<>(this, null, proxy, TxnFlags.MDB_RDONLY_TXN);
} catch (final RuntimeException e) {
if (tracked) {
liveTxns.decrementAndGet();
}
throw e;
}
}

/**
* Obtain a read-write transaction.
*
* <p>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<T> txnWrite() {
checkNotClosed();
return new Txn<>(this, null, proxy, TxnFlagSet.EMPTY);
final boolean tracked = beforeTxnBeginIfTracked();
try {
return new Txn<>(this, null, proxy, TxnFlagSet.EMPTY);
} catch (final RuntimeException e) {
if (tracked) {
liveTxns.decrementAndGet();
}
throw e;
}
}

Pointer pointer() {
Expand Down Expand Up @@ -663,6 +846,23 @@ public AlreadyClosedException() {
}
}

/**
* {@link Env#close(Duration)} timed out because transactions were still open. The environment has
* <em>not</em> been closed (its memory map is still mapped); the caller should ensure the
* offending transactions are closed and retry, rather than forcing an unsafe {@link Env#close()}.
*/
public static final class CloseTimeoutException extends LmdbException {

private static final long serialVersionUID = 1L;

CloseTimeoutException(final int openTransactions) {
super(
"close(Duration) timed out while "
+ openTransactions
+ " transaction(s) were still open");
}
}

/** Object has already been opened and the operation is therefore prohibited. */
public static final class AlreadyOpenException extends LmdbException {

Expand Down Expand Up @@ -691,6 +891,7 @@ public static final class Builder<T> {
private boolean opened;
private final BufferProxy<T> proxy;
private int mode = POSIX_MODE_DEFAULT;
private boolean safeClose;
private final AbstractFlagSet.Builder<EnvFlags, EnvFlagSet> flagSetBuilder =
EnvFlagSet.builder();

Expand Down Expand Up @@ -766,7 +967,7 @@ public Env<T> 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, safeClose);
} catch (final LmdbNativeException e) {
LIB.mdb_env_close(ptr);
throw e;
Expand Down Expand Up @@ -833,6 +1034,27 @@ public Builder<T> setMaxReaders(final int readers) {
return this;
}

/**
* Enables the opt-in "safe close" for the resulting {@link Env}.
*
* <p>When enabled, the environment tracks its live transactions so {@link Env#close(Duration)}
* can drain in-flight readers before unmapping, instead of risking the JVM crash described on
* {@link Env#close()}. This adds a small amount of bookkeeping on transaction start/close; it
* is <strong>disabled by default</strong> so applications that already manage their own
* threading (the common low-latency case) pay nothing. It does not change the behaviour of the
* no-arg {@link Env#close()}.
*
* @param safeClose true to enable transaction tracking and {@link Env#close(Duration)}
* @return the builder
*/
public Builder<T> setSafeClose(final boolean safeClose) {
if (opened) {
throw new AlreadyOpenException();
}
this.safeClose = safeClose;
return this;
}

/**
* Sets the Unix file permissions to use on created files and semaphores, e.g. {@code 0664}. If
* this method is not called, the default of {@code 0664} will be used.
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/org/lmdbjava/Txn.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ public void close() {
}
keyVal.close();
state = RELEASED;
// Balance the registration performed by Env's txn factory when safe close is enabled. No-op
// otherwise. Runs once, since the RELEASED guard above makes close() idempotent.
env.afterTxnClosed();
}

/** Commits this transaction. */
Expand Down
Loading