Skip to content
Open
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
81 changes: 81 additions & 0 deletions src/main/java/org/lmdbjava/ByteBufProxy.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import java.lang.reflect.Field;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Comparator;
import jnr.ffi.Pointer;
Expand Down Expand Up @@ -221,4 +222,84 @@ protected ByteBuf out(final ByteBuf buffer, final Pointer ptr) {
buffer.clear().writerIndex((int) size);
return buffer;
}

/**
* Returns a read-only NIO {@link ByteBuffer} view over the LMDB memory currently referenced by
* the readable region of {@code buffer} — typically a {@link ByteBuf} just returned from a read
* using {@link #PROXY_NETTY}.
*
* <p><b>Why this is needed.</b> For zero copy, a {@code ByteBuf} returned from a read is
* repointed at LMDB's memory-mapped region by overwriting its {@code memoryAddress} (see {@link
* #out}). That makes the {@code ByteBuf}'s own accessors (e.g. {@link ByteBuf#getBytes(int,
* byte[])}) read the LMDB data correctly, but {@link ByteBuf#nioBuffer()} derives its buffer from
* Netty's separate, chunk-shared backing buffer, which was never repointed — so {@code
* buffer.nioBuffer()} yields zeros. (That chunk buffer cannot simply be repointed, as it is
* shared by every pooled buffer carved from the same chunk.) This method instead materialises a
* NIO buffer that actually aliases the LMDB region, so it can be handed to NIO-based consumers
* (compression, hashing, etc.).
*
* <p><b>Lifecycle — read carefully.</b> The returned buffer aliases LMDB-owned, read-only memory.
* It is valid ONLY while the owning read {@link Txn} remains open and unmodified, exactly like
* {@link Txn#val()}. Using it after that transaction (or the {@link Env}) is closed, or after a
* write to the same slot, is undefined behaviour that may crash the JVM (SIGSEGV). If you need
* the bytes to outlive the transaction, use {@link #nioBufferCopy(ByteBuf)} instead.
*
* @param buffer a {@link ByteBuf} whose readable region points at LMDB memory (required)
* @return a read-only NIO view over the same memory (never null)
*/
public static ByteBuffer nioBufferView(final ByteBuf buffer) {
requireNonNull(buffer);
// Start from a real direct buffer, then repoint its single java.nio.Buffer address/capacity at
// the LMDB region — the same technique ByteBufferProxy uses for its own zero-copy NIO buffers.
// The original (zero-length) allocation stays referenced by the read-only view's attachment, so
// its Cleaner frees only that original base, never the LMDB memory we aliased.
final ByteBuffer view = ByteBuffer.allocateDirect(0);
UNSAFE.putLong(
view, NioBufferField.ADDRESS_OFFSET, buffer.memoryAddress() + buffer.readerIndex());
UNSAFE.putInt(view, NioBufferField.CAPACITY_OFFSET, buffer.readableBytes());
view.clear();
return view.asReadOnlyBuffer();
}

/**
* Returns a direct NIO {@link ByteBuffer} holding an independent <b>copy</b> of the readable
* region of {@code buffer} — typically a {@link ByteBuf} just returned from a read using {@link
* #PROXY_NETTY}.
*
* <p>This is the safe counterpart to {@link #nioBufferView(ByteBuf)}. It addresses the same
* lmdbjava#215 problem (a get-returned {@code ByteBuf} whose {@link ByteBuf#nioBuffer()} yields
* zeros), but by copying the bytes out via the {@code ByteBuf}'s own (correct) accessor rather
* than aliasing LMDB memory. The copy therefore uses no {@code Unsafe}/reflection, needs no
* {@code --add-opens}, and — crucially — <b>remains valid after the transaction (or {@link Env})
* is closed</b>. Prefer this unless you specifically need zero copy and can guarantee the
* returned buffer is consumed before the owning transaction closes.
*
* <p>The returned buffer is a freshly allocated direct buffer, flipped and ready to read ({@code
* position=0}, {@code limit=size}). The caller owns it.
*
* @param buffer a {@link ByteBuf} whose readable region holds the bytes to copy (required)
* @return a new direct NIO buffer containing a copy of those bytes (never null)
*/
public static ByteBuffer nioBufferCopy(final ByteBuf buffer) {
requireNonNull(buffer);
final ByteBuffer copy = ByteBuffer.allocateDirect(buffer.readableBytes());
// getBytes reads through the ByteBuf's (LMDB-repointed) memoryAddress, so it copies the real
// stored bytes — not the zeros seen via ByteBuf.nioBuffer().
buffer.getBytes(buffer.readerIndex(), copy);
copy.flip();
return copy;
}

/**
* Lazily-resolved offsets of {@link java.nio.Buffer}'s {@code address}/{@code capacity} fields.
* Kept in a holder (not a static field on {@link ByteBufProxy}) so that a JVM lacking the
* required {@code --add-opens java.base/java.nio} only fails if {@link #nioBufferView(ByteBuf)}
* is actually called, rather than breaking {@link #PROXY_NETTY} initialisation for every user.
*/
private static final class NioBufferField {
static final long ADDRESS_OFFSET =
UNSAFE.objectFieldOffset(findField("java.nio.Buffer", "address"));
static final long CAPACITY_OFFSET =
UNSAFE.objectFieldOffset(findField("java.nio.Buffer", "capacity"));
}
}
175 changes: 175 additions & 0 deletions src/test/java/org/lmdbjava/ByteBufNioBufferTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
* Copyright © 2016-2025 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 java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import static org.lmdbjava.DbiFlags.MDB_CREATE;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
* Reproduces and fixes lmdbjava#215: {@code ByteBuf.nioBuffer()} on a value returned via {@link
* ByteBufProxy#PROXY_NETTY} yields zeros. Two complementary helpers are offered:
*
* <ul>
* <li>{@link ByteBufProxy#nioBufferView(ByteBuf)} — zero-copy, valid only within the txn.
* <li>{@link ByteBufProxy#nioBufferCopy(ByteBuf)} — an independent copy that outlives the txn.
* </ul>
*/
final class ByteBufNioBufferTest {

private static final String VALUE = "Hello World";
private static final byte[] VALUE_BYTES = VALUE.getBytes(UTF_8);

private TempDir tempDir;

@BeforeEach
void beforeEach() {
tempDir = new TempDir();
}

@AfterEach
void afterEach() {
tempDir.cleanup();
}

private Env<ByteBuf> openEnv() {
final Path dir = tempDir.createTempDir();
return Env.create(ByteBufProxy.PROXY_NETTY).setMapSize(10_485_760).setMaxDbs(1).open(dir);
}

private static Dbi<ByteBuf> openDb(final Env<ByteBuf> env) {
return env.createDbi().setDbName("db").withDefaultComparator().addDbiFlag(MDB_CREATE).open();
}

private static byte[] readable(final ByteBuf buffer) {
final byte[] dst = new byte[buffer.readableBytes()];
buffer.getBytes(buffer.readerIndex(), dst);
return dst;
}

private static byte[] drain(final ByteBuffer buffer) {
final byte[] dst = new byte[buffer.remaining()];
buffer.get(dst);
return dst;
}

/** Zero-copy view reflects the stored bytes (read inside the txn). */
@Test
void nioBufferView_reflectsStoredData() {
try (Env<ByteBuf> env = openEnv()) {
final Dbi<ByteBuf> db = openDb(env);
final ByteBuf key = PooledByteBufAllocator.DEFAULT.directBuffer(env.getMaxKeySize());
final ByteBuf value = PooledByteBufAllocator.DEFAULT.directBuffer(64);
try {
key.writeCharSequence("greeting", UTF_8);
value.writeCharSequence(VALUE, UTF_8);
db.put(key, value);
try (Txn<ByteBuf> txn = env.txnRead()) {
final ByteBuf found = db.get(txn, key);
assertThat(found).isNotNull();
assertThat(readable(found)).isEqualTo(VALUE_BYTES); // sanity
assertThat(drain(ByteBufProxy.nioBufferView(found))).isEqualTo(VALUE_BYTES);
}
} finally {
key.release();
value.release();
}
}
}

/** Copy reflects the stored bytes. */
@Test
void nioBufferCopy_reflectsStoredData() {
try (Env<ByteBuf> env = openEnv()) {
final Dbi<ByteBuf> db = openDb(env);
final ByteBuf key = PooledByteBufAllocator.DEFAULT.directBuffer(env.getMaxKeySize());
final ByteBuf value = PooledByteBufAllocator.DEFAULT.directBuffer(64);
try {
key.writeCharSequence("greeting", UTF_8);
value.writeCharSequence(VALUE, UTF_8);
db.put(key, value);
try (Txn<ByteBuf> txn = env.txnRead()) {
final ByteBuf found = db.get(txn, key);
assertThat(found).isNotNull();
assertThat(drain(ByteBufProxy.nioBufferCopy(found))).isEqualTo(VALUE_BYTES);
}
} finally {
key.release();
value.release();
}
}
}

/** The discriminator: a copy taken inside the txn is still valid after txn AND env are closed. */
@Test
void nioBufferCopy_survivesTxnAndEnvClose() {
final Env<ByteBuf> env = openEnv();
final ByteBuf key = PooledByteBufAllocator.DEFAULT.directBuffer(env.getMaxKeySize());
final ByteBuf value = PooledByteBufAllocator.DEFAULT.directBuffer(64);
ByteBuffer copy = null;
try {
final Dbi<ByteBuf> db = openDb(env);
key.writeCharSequence("greeting", UTF_8);
value.writeCharSequence(VALUE, UTF_8);
db.put(key, value);
try (Txn<ByteBuf> txn = env.txnRead()) {
copy = ByteBufProxy.nioBufferCopy(db.get(txn, key));
}
} finally {
key.release();
value.release();
env.close(); // both txn and env are now closed
}
assertThat(copy).isNotNull();
assertThat(drain(copy)).isEqualTo(VALUE_BYTES); // copy is independent of LMDB memory
}

/**
* Documents the lmdbjava#215 limitation: the raw {@link ByteBuf#nioBuffer()} does NOT reflect the
* LMDB data (it views Netty's separate, never-repointed chunk buffer). This is why the two
* helpers exist.
*/
@Test
void rawByteBufNioBuffer_doesNotReflectStoredData() {
try (Env<ByteBuf> env = openEnv()) {
final Dbi<ByteBuf> db = openDb(env);
final ByteBuf key = PooledByteBufAllocator.DEFAULT.directBuffer(env.getMaxKeySize());
final ByteBuf value = PooledByteBufAllocator.DEFAULT.directBuffer(64);
try {
key.writeCharSequence("greeting", UTF_8);
value.writeCharSequence(VALUE, UTF_8);
db.put(key, value);
try (Txn<ByteBuf> txn = env.txnRead()) {
final ByteBuf found = db.get(txn, key);
assertThat(found).isNotNull();
assertThat(readable(found)).isEqualTo(VALUE_BYTES);
assertThat(drain(found.nioBuffer())).isNotEqualTo(VALUE_BYTES);
}
} finally {
key.release();
value.release();
}
}
}
}