Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright DataStax, Inc.
*
* 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 io.github.jbellis.jvector.disk;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
* {@link SeekableSink} over a {@link FileChannel}, translating region-relative positions by a fixed
* base offset. The channel is owned by the caller; {@link #close()} does not close it.
*/
final class FileChannelSeekableSink implements SeekableSink {
private final FileChannel channel;
private final long baseOffset;

FileChannelSeekableSink(FileChannel channel, long baseOffset) {
if (channel == null) {
throw new NullPointerException("channel");
}
if (baseOffset < 0) {
throw new IllegalArgumentException("baseOffset must be >= 0, got " + baseOffset);
}
this.channel = channel;
this.baseOffset = baseOffset;
}

@Override
public void writeAt(long position, ByteBuffer src) throws IOException {
if (position < 0) {
throw new IllegalArgumentException("position must be >= 0, got " + position);
}
long abs = baseOffset + position;
while (src.hasRemaining()) {
abs += channel.write(src, abs);
}
}

@Override
public int readAt(long position, ByteBuffer dst) throws IOException {
if (position < 0) {
throw new IllegalArgumentException("position must be >= 0, got " + position);
}
return channel.read(dst, baseOffset + position);
}

@Override
public void force() throws IOException {
channel.force(false);
}

@Override
public void close() {
// The channel is owned by the caller, per SeekableSink.over(...).
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ public interface ReaderSupplier extends AutoCloseable {
default void prefetch(long offset, long length) {
}

/**
* Releases the supplier's underlying resource. Two implementation families exist, with very
* different safety under concurrency:
* <ul>
* <li><b>Coordinated</b> (e.g. the jvector-native {@code MemorySegmentReader.Supplier}, whose
* shared-Arena close performs a liveness handshake): closing while vended readers are still
* in use degrades to {@code IllegalStateException} on those readers.</li>
* <li><b>Raw-release</b> (e.g. {@link SimpleMappedReader.Supplier}, which unmaps
* immediately): closing while any vended reader is mid-read invalidates the mapped pages
* underneath it, and the JVM fails with a native fault (SIGSEGV) rather than an
* exception.</li>
* </ul>
* Callers must not close a supplier until every reader vended by {@link #get()} is provably
* quiescent; implementations should document which family they belong to.
*
* @throws IOException if an I/O error occurs
*/
default void close() throws IOException {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright DataStax, Inc.
*
* 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 io.github.jbellis.jvector.disk;

import io.github.jbellis.jvector.annotations.Experimental;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
* A seekable region that supports positional reads and writes, addressed in coordinates relative
* to the region's start (0-based). An embedder uses it to hand a compactor (or other writer) a
* bounded window inside a larger container file: positions are region-relative and the
* implementation adds the container's base offset, so the writer never needs to know the absolute
* offset.
*
* <p>Implementations must support concurrent positional writes and reads to disjoint ranges (a
* {@link FileChannel} does). This is a generic IO primitive; the compaction extension point that
* hands one out is {@code io.github.jbellis.jvector.graph.disk.CompactionDestination}.
*/
@Experimental
public interface SeekableSink extends AutoCloseable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interface appears to serve the same purpose as RandomAccessReader/ReaderSupplier + RandomAccessWriter/IndexWriter. Is this intention to completely replace those existing interfaces with this one? If so, it would be helpful to jot down the advantages of this approach over what we already have.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly captured in the javadoc above, but the main benefit here is to virtualize the output stream as logically owned by the jvector writer and physically owned by the embedding system. Specifically, this allows jvector to persist its indexes "care of" the owning system, which allows the writes to only happen once.

The alternative is that jvector owns the raw file management, and presumes everything to be jvector-only, full file ownership, 0-indexed. This is not true in practice, and by forcing it, we have caused other systems to have to copy and recopy data in order to properly virtualize it into the owning system's data formats and filesystem conventions.

The operative benefit is described in the javadoc above as:

An embedder uses it to hand a compactor (or other writer) a bounded window inside a larger container file: positions are region-relative and the implementation adds the container's base offset, so the writer never needs to know the absolute offset.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a more mechanical explanation of some of the decision points, with help from analysis:

RandomAccessReader/Writer are stateful cursor APIs (seek, then read/write sequentially), documented as not threadsafe, which is why ReaderSupplier exists to create one per thread. IndexWriter is a typed DataOutput serialization surface on top of that.

SeekableSink is a stateless positional-I/O primitive: writeAt/readAt with no cursor, where a single instance must support concurrent reads and writes to disjoint ranges. It also addresses a region — positions are relative to a base offset inside a caller-owned channel, and close() doesn't close the channel. That combination is what the CompactionDestination extension point needs: an embedder hands the compactor a bounded window inside its own container file (write the body, read it back for checksumming, on one handle) without exposing absolute offsets or giving up channel lifecycle.

None of that fits the existing interfaces without changing their documented threading/addressing contracts for every current implementation, so this is additive rather than a migration. If anything, the relationship is layered: a format-aware writer like IndexWriter could be implemented over a SeekableSink region.


/** Write {@code src} fully at region-relative {@code position} (must be {@code >= 0}). */
void writeAt(long position, ByteBuffer src) throws IOException;

/**
* Read up to {@code dst.remaining()} bytes at region-relative {@code position} (must be
* {@code >= 0}); returns the number of bytes read, or {@code -1} at end of region.
*/
int readAt(long position, ByteBuffer dst) throws IOException;

/** Force written bytes to durable storage. */
void force() throws IOException;

@Override
void close() throws IOException;

/**
* Reference implementation over a {@link FileChannel} region. Every region-relative position is
* translated by {@code baseOffset}. The channel's lifecycle is owned by the caller — this
* {@link #close()} does <b>not</b> close the channel.
*
* @param channel the backing channel, opened for read and write
* @param baseOffset the absolute offset of the region's start within {@code channel} ({@code >= 0})
*/
static SeekableSink over(FileChannel channel, long baseOffset) {
return new FileChannelSeekableSink(channel, baseOffset);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ public SimpleMappedReader get() {
return new SimpleMappedReader((MappedByteBuffer) buffer.duplicate());
}

/**
* Unmaps the shared mapping <b>immediately</b> (via {@code Unsafe.invokeCleaner}), with
* no coordination with outstanding readers — the raw-release family of
* {@link ReaderSupplier#close()}. Any reader vended by {@link #get()} that touches the
* mapping after this call faults natively (SIGSEGV) rather than throwing an exception,
* so close only once every vended reader is provably done. Where JDK 22+ is available,
* prefer the jvector-native {@code MemorySegmentReader}, whose close degrades to
* {@code IllegalStateException} instead.
*/
@Override
public void close() {
if (unsafe != null) {
Expand Down
Loading
Loading