diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/FileChannelSeekableSink.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/FileChannelSeekableSink.java new file mode 100644 index 000000000..bcc3bd412 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/FileChannelSeekableSink.java @@ -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(...). + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ReaderSupplier.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ReaderSupplier.java index 7ebb3f9b0..8827bcdb1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ReaderSupplier.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ReaderSupplier.java @@ -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: + *
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 {
+
+ /** 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 not 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);
+ }
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleMappedReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleMappedReader.java
index 46d91f8e3..142d6af6c 100644
--- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleMappedReader.java
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleMappedReader.java
@@ -84,6 +84,15 @@ public SimpleMappedReader get() {
return new SimpleMappedReader((MappedByteBuffer) buffer.duplicate());
}
+ /**
+ * Unmaps the shared mapping immediately (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) {
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ChunkingParallelExecutor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ChunkingParallelExecutor.java
new file mode 100644
index 000000000..3ce220950
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ChunkingParallelExecutor.java
@@ -0,0 +1,248 @@
+/*
+ * 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.graph;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.PrimitiveIterator;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.function.Consumer;
+import java.util.function.IntConsumer;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+/**
+ * {@link ParallelExecutor} over a caller-supplied {@link ExecutorService}: the calling thread
+ * traverses the source and submits fixed-size chunks to the executor, keeping a bounded number in
+ * flight. Backs {@link ParallelExecutor#over(ExecutorService, int)}; see the {@link ParallelExecutor}
+ * class javadoc for how this implementation's caveats compare with the other factories.
+ */
+final class ChunkingParallelExecutor implements ParallelExecutor {
+ /** Elements per submitted chunk on the stream paths ({@code forEachInt} splits its range evenly instead). */
+ private static final int BATCH_SIZE = 32;
+ /** Chunks per worker on the {@code forEachInt} path: more than one smooths skewed per-element cost. */
+ private static final int CHUNKS_PER_WORKER = 4;
+
+ // Nested use from a body degrades to inline execution: a chunk that blocked on sub-chunks of
+ // the same bounded executor could starve it into deadlock (every worker waiting on chunks that
+ // can never be scheduled).
+ private static final ThreadLocal
+ * This is the seam that lets an embedder bound vector-graph construction to its own thread budget —
+ * e.g. one thread per compaction — instead of a jvector-owned all-core pool. It is the build/finalize
+ * counterpart to the caller-runs executor injection already available on the compaction merge path.
+ *
+ *
+ * {@code parallelism} must be stated explicitly because {@code ExecutorService} does not expose
+ * its width: state the executor's actual thread count — a larger value merely queues, a smaller
+ * one under-uses it. Passing a {@link ForkJoinPool} delegates to {@link #forkJoin(ForkJoinPool)}
+ * (whole-pipeline stream decomposition is strictly better there) and {@code parallelism} is
+ * ignored in that case.
+ *
+ * @param executor runs the chunked iterations; its lifecycle remains the caller's (never shut down here)
+ * @param parallelism the intended number of chunks in flight, typically the executor's thread
+ * count; must be {@code >= 1}
+ * @return a chunk-submitting {@code ParallelExecutor}
+ * @throws IllegalArgumentException if {@code parallelism < 1}
+ */
+ static ParallelExecutor over(ExecutorService executor, int parallelism) {
+ Objects.requireNonNull(executor, "executor");
+ if (parallelism < 1) {
+ throw new IllegalArgumentException("parallelism must be >= 1, got " + parallelism);
+ }
+ if (executor instanceof ForkJoinPool) {
+ return forkJoin((ForkJoinPool) executor);
+ }
+ return new ChunkingParallelExecutor(executor, parallelism);
+ }
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactionDestination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactionDestination.java
new file mode 100644
index 000000000..2dbaf5cd1
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactionDestination.java
@@ -0,0 +1,91 @@
+/*
+ * 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.graph.disk;
+
+import io.github.jbellis.jvector.annotations.Experimental;
+import io.github.jbellis.jvector.disk.SeekableSink;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/**
+ * Embedding extension point: tells {@link OnDiskGraphIndexCompactor} WHERE to write its compacted
+ * graph, so the body lands directly inside the embedder's container (after a header the embedder
+ * reserves) — eliminating the temp-file-and-copy. The destination itself is stateless
+ * configuration an embedder can build once and hold; each {@code compact(...)} call
+ * {@link #reserve() reserves} its own {@link OutputReservation}, commits it on success, and always
+ * closes it.
+ *
+ * The compactor needs a real file (it uses a memory-mapped read-back during refinement and a
+ * random-access writer), so an {@link OutputReservation} is expressed as a container {@link Path}
+ * plus a base offset rather than an opaque stream. The generic {@link SeekableSink} primitive
+ * addresses the same window in region-relative coordinates and is what an embedder uses to read the
+ * committed body back for its checksum, e.g. {@code SeekableSink.over(channel, r.startOffset())}.
+ */
+@FunctionalInterface
+@Experimental
+public interface CompactionDestination {
+
+ /** Reserve a fresh output region for one compaction. */
+ OutputReservation reserve() throws IOException;
+
+ /**
+ * One compaction's reserved output region plus its commit/abort lifecycle: live, single-use
+ * state made against the destination, fulfilled by {@link #commit} or released by a
+ * {@link #close} without one.
+ */
+ interface OutputReservation extends AutoCloseable {
+
+ /** The container file the graph body is written into. */
+ Path file();
+
+ /** The byte offset within {@link #file()} at which the graph body begins ({@code >= 0}). */
+ long startOffset();
+
+ /**
+ * Fulfills the reservation. Signalled exactly once, after the body has been fully written
+ * and forced, reporting its length ({@code file() size - startOffset()}). The embedder
+ * finalizes its container here (e.g. writes a footer/checksum). MUST be called before
+ * {@link #close()} on the success path.
+ */
+ void commit(long bodyLength) throws IOException;
+
+ /**
+ * Always runs (try-with-resources). If reached without a prior {@link #commit}, the
+ * compaction failed: the reservation is released, the embedder discards the partial
+ * output, and embedder resources are freed.
+ */
+ @Override
+ void close() throws IOException;
+ }
+
+ /**
+ * Default standalone destination: writes to its own file at offset {@code 0} (today's
+ * {@code compact(Path)} behaviour). {@code commit} is a no-op marker; a {@code close} without a
+ * prior commit deletes the partial file.
+ */
+ static CompactionDestination toFile(Path path) {
+ return new FileCompactionDestination(path);
+ }
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/FileCompactionDestination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/FileCompactionDestination.java
new file mode 100644
index 000000000..b9a972fd6
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/FileCompactionDestination.java
@@ -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.graph.disk;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+
+/**
+ * {@link CompactionDestination} that writes a standalone graph file at offset 0. Backs
+ * {@link CompactionDestination#toFile(Path)}.
+ */
+final class FileCompactionDestination implements CompactionDestination {
+ private final Path path;
+
+ FileCompactionDestination(Path path) {
+ Objects.requireNonNull(path, "path");
+ this.path = path;
+ }
+
+ @Override
+ public OutputReservation reserve() {
+ return new OutputReservation() {
+ private boolean committed;
+
+ @Override
+ public Path file() {
+ return path;
+ }
+
+ @Override
+ public long startOffset() {
+ return 0L;
+ }
+
+ @Override
+ public void commit(long bodyLength) {
+ // Standalone file: the graph IS the whole file; compact() already wrote and flushed it.
+ committed = true;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (!committed) {
+ Files.deleteIfExists(path); // abort: discard the partial file
+ }
+ }
+ };
+ }
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java
index ba34e2cb0..915eb08c1 100644
--- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java
@@ -227,7 +227,10 @@ private Int2ObjectHashMap
+ * Footer loading trusts the end of the supplier's range. With {@code useFooter=true}
+ * and a v5+ graph, metadata is located relative to the reader's {@code length()} — the file
+ * end, for whole-file suppliers. That is only correct when the graph is the last
+ * content in the supplier's range. Never footer-load a reused or embedder-owned container
+ * whose length extends past the graph body: stale bytes there either fail the load loudly
+ * or, if they end in a stale-but-still-valid footer, silently resurrect the old graph over
+ * the new bytes. For such containers, pass {@code useFooter=false} with the known
+ * {@code offset}, or use a region-bounded {@link ReaderSupplier} whose {@code length()} is
+ * the end of the graph's region.
*
* @param readerSupplier the reader supplier to use to read the graph and index.
* @param offset the offset in bytes from the start of the file where the index starts.
@@ -270,6 +283,9 @@ public static OnDiskGraphIndex load(ReaderSupplier readerSupplier, long offset,
/**
* Load an index from the given reader supplier where header and graph are located on the same file at offset 0.
+ * For v5+ graphs, metadata is located via the footer at the end of the supplier's range —
+ * the supplier must contain the graph and nothing after it; see the
+ * {@link #load(ReaderSupplier, long, boolean)} warning.
*
* @param readerSupplier the reader supplier to use to read the graph index.
*/
@@ -494,6 +510,21 @@ public View(RandomAccessReader reader) {
this.neighbors = new int[layerInfo.stream().mapToInt(li -> li.degree).max().orElse(0)];
}
+ /**
+ * Guards every on-disk record access. A node ordinal outside the L0 record space
+ * ({@code idUpperBound}, which exceeds {@code size(0)} for graphs renumbered with holes)
+ * would otherwise become a silent wild offset into the mapped file — reading garbage (or
+ * faulting) instead of failing diagnosably. Both package-private offset entry points call
+ * this, so each access is validated exactly once, with a single branch against a final
+ * bound.
+ */
+ private void requireValidNode(int node) {
+ if (node < 0 || node >= idUpperBound) {
+ throw new IllegalArgumentException(
+ "node ordinal " + node + " out of range [0, " + idUpperBound + ") for this graph");
+ }
+ }
+
@Override
public int dimension() {
return dimension;
@@ -512,6 +543,7 @@ public RandomAccessVectorValues copy() {
// package-private: OnDiskGraphIndexCompactor uses this for in-place neighbor refinement
long offsetFor(int node, FeatureId featureId) {
+ requireValidNode(node);
Feature feature = features.get(featureId);
// Separated features are just global offset + node offset
@@ -528,6 +560,7 @@ long offsetFor(int node, FeatureId featureId) {
// package-private: OnDiskGraphIndexCompactor uses this for in-place neighbor refinement
long neighborsOffsetFor(int level, int node) {
+ requireValidNode(node);
assert level == 0; // higher layers are in memory
// skip node ID + inline features
@@ -588,8 +621,14 @@ public NodesIterator getNeighborsIterator(int level, int node) {
// For layer 0, read from disk
reader.seek(neighborsOffsetFor(level, node));
nodeDegree = reader.readInt();
- assert nodeDegree <= neighbors.length
- : String.format("Node %d neighborCount %d > M %d", node, nodeDegree, neighbors.length);
+ if (nodeDegree < 0 || nodeDegree > neighbors.length) {
+ // A real check, not an assert: an out-of-range on-disk degree means the
+ // block is corrupt or the metadata is stale, and the garbage ints that a
+ // blind read would yield become out-of-range node ids downstream.
+ throw new IllegalStateException(String.format(
+ "Corrupt neighbor block: node %d at level 0 declares degree %d outside [0, %d] (block offset %d)",
+ node, nodeDegree, neighbors.length, neighborsOffsetFor(level, node)));
+ }
reader.read(neighbors, 0, nodeDegree);
stored = neighbors;
} else {
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/RuntimeMode.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/RuntimeMode.java
new file mode 100644
index 000000000..50758893b
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/RuntimeMode.java
@@ -0,0 +1,89 @@
+/*
+ * 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.util;
+
+import java.util.Locale;
+import java.util.logging.Logger;
+
+/**
+ * Library-wide runtime mode, from the {@code jvector.mode} system property.
+ *
+ * Two modes: {@code prod} (or {@code production}) and {@code dev} (or
+ * {@code development}), case-insensitive. The mode gates diagnostic
+ * walks — computations whose value is re-derived from first principles by
+ * traversing a structure, such as {@link
+ * io.github.jbellis.jvector.quantization.PQVectors#ramBytesUsed()} summing
+ * every compressed chunk. In production mode such walks are categorically
+ * replaced by incrementally-maintained values; in development mode they run in
+ * full, so a drifted cache or accounting bug is observable.
+ *
+ * Default is production. An embedding host once called the chunk
+ * walk once per inserted vector, turning index-build accounting into
+ * O(n²) — a single compaction burned two CPU-hours inside
+ * {@code ramBytesUsed} while build workers starved. A diagnostic full-walk is
+ * something a developer opts into, not something a production host should
+ * have to know to opt out of.
+ *
+ * Unrecognized values log a warning and resolve to production, not
+ * development: falling back to the diagnostic mode on a typo would silently
+ * reintroduce exactly the pathology above.
+ */
+public final class RuntimeMode {
+ private static final Logger LOG = Logger.getLogger(RuntimeMode.class.getName());
+
+ public static final String PROPERTY = "jvector.mode";
+
+ private static final boolean DEVELOPMENT =
+ parseIsDevelopment(System.getProperty(PROPERTY), LOG);
+
+ private RuntimeMode() {
+ }
+
+ /** True when diagnostic walks should run in full. */
+ public static boolean isDevelopment() {
+ return DEVELOPMENT;
+ }
+
+ /** True when diagnostic walks are replaced by maintained values. */
+ public static boolean isProduction() {
+ return !DEVELOPMENT;
+ }
+
+ /**
+ * Pure parse, exposed for tests (the static mode is fixed at class load).
+ */
+ static boolean parseIsDevelopment(String raw, Logger log) {
+ if (raw == null) {
+ return false;
+ }
+ switch (raw.trim().toLowerCase(Locale.ROOT)) {
+ case "dev":
+ case "development":
+ return true;
+ case "prod":
+ case "production":
+ case "":
+ return false;
+ default:
+ log.warning(() -> PROPERTY + "=" + raw
+ + " is not recognized (expected prod|production|dev|development); "
+ + "using production. Development mode re-enables full diagnostic "
+ + "walks and must be asked for exactly.");
+ return false;
+ }
+ }
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/LeakyBucketLimiter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/LeakyBucketLimiter.java
new file mode 100644
index 000000000..4f31f0431
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/LeakyBucketLimiter.java
@@ -0,0 +1,68 @@
+/*
+ * 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.util.work;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A leaky-bucket rate meter realizing the {@link WorkLimiter} facet: {@link #acquire} paces the
+ * aggregate admitted amount to a fixed {@code unitsPerSecond}, blocking the caller when the rate
+ * would be exceeded. The bucket drains during idle gaps (a burst after a quiet period is not
+ * charged for the idle time), and the first request after an idle period is admitted without
+ * delay — the cost of each request is paid by the next one, which is the standard smooth
+ * shaping behaviour. {@link #startPhase} is inherited as a no-op scope factory: this limiter only
+ * throttles.
+ *
+ * Thread-safe and reentrant: the emission clock is advanced under a short lock, then the caller
+ * sleeps outside the lock, so concurrent callers serialize their reservations but wait
+ * independently. The returned grant is a no-op — the cost is paid entirely at {@code acquire}.
+ *
+ * Obtain instances via {@link ProgressLimiter#rateLimited(double)}.
+ */
+final class LeakyBucketLimiter implements ProgressLimiter {
+ private final double nanosPerUnit;
+ private final Object lock = new Object();
+ // Earliest nanoTime at which the next reservation may start. Long.MIN_VALUE until the first
+ // acquire, so Math.max(now, nextFreeNanos) == now (a fully drained bucket) on the first call.
+ private long nextFreeNanos = Long.MIN_VALUE;
+
+ LeakyBucketLimiter(double unitsPerSecond) {
+ if (!(unitsPerSecond > 0) || Double.isInfinite(unitsPerSecond)) {
+ throw new IllegalArgumentException("unitsPerSecond must be finite and > 0, got " + unitsPerSecond);
+ }
+ this.nanosPerUnit = 1_000_000_000.0 / unitsPerSecond;
+ }
+
+ @Override
+ public Grant acquire(long amount) throws InterruptedException {
+ if (amount <= 0) {
+ return Grant.NOOP;
+ }
+ long startAt;
+ synchronized (lock) {
+ long now = System.nanoTime();
+ startAt = Math.max(now, nextFreeNanos); // drain if idle, else queue behind backlog
+ long cost = (long) Math.min((double) Long.MAX_VALUE, amount * nanosPerUnit);
+ nextFreeNanos = startAt + cost;
+ }
+ // Sleep (interruptibly, so cancellation aborts) until this request's slot opens.
+ for (long remaining = startAt - System.nanoTime(); remaining > 0; remaining = startAt - System.nanoTime()) {
+ TimeUnit.NANOSECONDS.sleep(remaining);
+ }
+ return Grant.NOOP;
+ }
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/ProgressLimiter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/ProgressLimiter.java
new file mode 100644
index 000000000..481f88406
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/ProgressLimiter.java
@@ -0,0 +1,114 @@
+/*
+ * 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.util.work;
+
+import io.github.jbellis.jvector.annotations.Experimental;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * The {@link ProgressTracker tracker} and the {@link WorkLimiter throttle} melded into one control
+ * surface. A long-running jvector operation accepts a single {@code ProgressLimiter} and uses both
+ * facets; an embedder may override only the facet it needs — the other defaults to a no-op, so
+ * {@link #UNLIMITED} behaves exactly as if no SPI were installed.
+ *
+ * Both methods default to no-ops here. A consumer that wants only one facet can still accept a
+ * lambda via the single-method parents ({@link ProgressTracker}, {@link WorkLimiter}); a consumer
+ * that wants both accepts a {@code ProgressLimiter}.
+ */
+@Experimental
+public interface ProgressLimiter extends ProgressTracker, WorkLimiter {
+
+ @Override
+ default PhaseScope startPhase(WorkStage stage) { return PhaseScope.NOOP; }
+
+ @Override
+ default Grant acquire(long amount) throws InterruptedException { return Grant.NOOP; }
+
+ /** Observes nothing and limits nothing — behaviour identical to no SPI installed. */
+ ProgressLimiter UNLIMITED = new ProgressLimiter() { };
+
+ /**
+ * A leaky-bucket rate meter realizing the throttle facet: {@link #acquire} paces the aggregate
+ * admitted amount to {@code unitsPerSecond} (bytes/sec for the compaction consumer), blocking
+ * the caller when the rate would be exceeded and draining during idle gaps. {@link #startPhase}
+ * returns a no-op scope and the returned grant is a no-op (cost is paid at {@code acquire}).
+ * Compose with {@link #logging(ProgressLimiter, Consumer)} to also log.
+ *
+ * @param unitsPerSecond the sustained admission rate; must be finite and {@code > 0}
+ * @throws IllegalArgumentException if {@code unitsPerSecond} is not finite and positive
+ */
+ static ProgressLimiter rateLimited(double unitsPerSecond) {
+ return new LeakyBucketLimiter(unitsPerSecond);
+ }
+
+ /**
+ * Wraps {@code delegate}, emitting a one-line message to {@code sink} on each phase start,
+ * each {@link PhaseScope#onProgress} report, each phase completion, and each {@link #acquire}
+ * that actually blocked, then delegating both facets to {@code delegate}. Composes over any
+ * limiter — e.g. {@code logging(rateLimited(bytesPerSecond), log::info)} logs a rate-limited
+ * operation. The delegate's grant is returned unchanged, so a semaphore delegate still
+ * releases on close.
+ *
+ * @param delegate the limiter to observe and delegate to; {@code null} means {@link #UNLIMITED}
+ * @param sink receives formatted log lines (e.g. {@code msg -> logger.info(msg)})
+ */
+ static ProgressLimiter logging(ProgressLimiter delegate, Consumer Best-effort and cheap: implementations must not throw from {@code startPhase},
+ * {@link PhaseScope#onProgress}, or {@link PhaseScope#close} (the caller invokes these on its
+ * orchestrating thread and treats them as fire-and-forget). The minimal consumer is a single
+ * expression — {@code stage -> (completed, total) -> bar.update(stage, completed, total)} — since
+ * {@code close()} defaults to a no-op. See {@link ProgressLimiter} for the melded progress +
+ * throttle surface that most consumers accept.
+ */
+@Experimental
+@FunctionalInterface
+public interface ProgressTracker {
+ /**
+ * Starts one phase of the operation. The returned scope receives that phase's progress and
+ * must be closed exactly once, normally with try-with-resources.
+ *
+ * @param stage identifies the phase's stage, in consumer-defined terms
+ */
+ PhaseScope startPhase(WorkStage stage);
+
+ /**
+ * One started phase: receives its progress reports and, on {@link #close}, its completion.
+ * {@code close()} defaults to a no-op so a progress-only implementation stays a single lambda.
+ */
+ @FunctionalInterface
+ interface PhaseScope extends AutoCloseable {
+ /**
+ * Reports progress for this phase.
+ *
+ * @param completed work done so far, in stage-defined units; monotonically non-decreasing
+ * within the phase
+ * @param total total work for the phase, or {@code -1} if not yet known
+ */
+ void onProgress(long completed, long total);
+
+ /** Marks the end of the phase. Called exactly once; defaults to a no-op. */
+ @Override
+ default void close() { }
+
+ /** A scope that discards every update. */
+ PhaseScope NOOP = (completed, total) -> { };
+ }
+
+ /** A tracker that discards every phase. */
+ ProgressTracker NOOP = stage -> PhaseScope.NOOP;
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/WorkLimiter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/WorkLimiter.java
new file mode 100644
index 000000000..5355f0757
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/WorkLimiter.java
@@ -0,0 +1,59 @@
+/*
+ * 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.util.work;
+
+import io.github.jbellis.jvector.annotations.Experimental;
+
+/**
+ * Admission contract: blocks until an amount of work may proceed, returning a {@link Grant} that
+ * the caller closes once the admitted work has completed.
+ *
+ * The unit of {@code amount} is defined by the consumer (e.g. bytes for IO, or rows,
+ * nodes, items); jvector fixes only the blocking-grant mechanism, never the meaning of the
+ * quantity. Implementations must be thread-safe and reentrant. {@code acquire} may block but must
+ * not throw for ordinary back-pressure.
+ */
+@Experimental
+@FunctionalInterface
+public interface WorkLimiter {
+ /**
+ * Blocks until {@code amount} units of work may proceed.
+ *
+ * @param amount the amount of work about to be performed, in consumer-defined units
+ * @return a non-null grant to {@link Grant#close() close} once that work has completed
+ * @throws InterruptedException if the calling thread is interrupted while blocked, which
+ * aborts the operation
+ */
+ Grant acquire(long amount) throws InterruptedException;
+
+ /**
+ * A handle released by the consumer once the admitted work has completed. For a rate-limiter
+ * realization (cost paid at {@link WorkLimiter#acquire}) {@link #close()} is a no-op; for a
+ * semaphore-style in-flight-amount realization it releases the permits taken by {@code acquire}.
+ */
+ interface Grant extends AutoCloseable {
+ /** Releases the grant. Never throws. */
+ @Override
+ void close();
+
+ /** A grant that holds nothing and releases nothing. */
+ Grant NOOP = () -> { };
+ }
+
+ /** A limiter that admits everything immediately. */
+ WorkLimiter UNLIMITED = amount -> Grant.NOOP;
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/WorkStage.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/WorkStage.java
new file mode 100644
index 000000000..001197a00
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/work/WorkStage.java
@@ -0,0 +1,33 @@
+/*
+ * 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.util.work;
+
+import io.github.jbellis.jvector.annotations.Experimental;
+
+/**
+ * Identifies a stage of a long-running operation. The consumer defines its own stages; an
+ * {@code enum} satisfies this for free via {@link Enum#name()}.
+ *
+ * Part of the generic progress + work-admission SPI ({@link ProgressTracker},
+ * {@link WorkLimiter}, {@link ProgressLimiter}). Neither the stage identity nor the unit of work
+ * is fixed by jvector; both are supplied by the consumer.
+ */
+@Experimental
+public interface WorkStage {
+ /** The stage's name, stable within a single operation. */
+ String name();
+}
diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestSeekableSink.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestSeekableSink.java
new file mode 100644
index 000000000..9fde6b162
--- /dev/null
+++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestSeekableSink.java
@@ -0,0 +1,85 @@
+/*
+ * 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 org.junit.Test;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class TestSeekableSink {
+
+ @Test
+ public void writesAndReadsInRegionRelativeCoordinates() throws IOException {
+ Path f = Files.createTempFile("sink", ".bin");
+ try (FileChannel ch = FileChannel.open(f, StandardOpenOption.WRITE, StandardOpenOption.READ)) {
+ long base = 100;
+ SeekableSink sink = SeekableSink.over(ch, base);
+ sink.writeAt(0, ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8)));
+ sink.writeAt(5, ByteBuffer.wrap("WORLD".getBytes(StandardCharsets.UTF_8)));
+ sink.force();
+
+ // Region-relative read returns what was written.
+ ByteBuffer dst = ByteBuffer.allocate(10);
+ assertEquals(10, sink.readAt(0, dst));
+ assertEquals("helloWORLD", new String(dst.array(), StandardCharsets.UTF_8));
+
+ // The bytes actually land at the absolute base offset (region-relative -> absolute).
+ ByteBuffer raw = ByteBuffer.allocate(10);
+ ch.read(raw, base);
+ assertEquals("helloWORLD", new String(raw.array(), StandardCharsets.UTF_8));
+
+ // Nothing was written before the region.
+ ByteBuffer before = ByteBuffer.allocate((int) base);
+ ch.read(before, 0);
+ for (byte b : before.array()) {
+ assertEquals("region must not write before its base", 0, b);
+ }
+
+ // close() must NOT close the caller-owned channel.
+ sink.close();
+ assertTrue("sink.close() must not close the caller's channel", ch.isOpen());
+ }
+ Files.deleteIfExists(f);
+ }
+
+ @Test
+ public void rejectsNegativeBaseAndPosition() throws IOException {
+ Path f = Files.createTempFile("sink", ".bin");
+ try (FileChannel ch = FileChannel.open(f, StandardOpenOption.WRITE, StandardOpenOption.READ)) {
+ try { SeekableSink.over(ch, -1); fail("negative base"); } catch (IllegalArgumentException expected) { }
+ SeekableSink sink = SeekableSink.over(ch, 0);
+ try { sink.writeAt(-1, ByteBuffer.allocate(1)); fail("negative write pos"); } catch (IllegalArgumentException expected) { }
+ try { sink.readAt(-1, ByteBuffer.allocate(1)); fail("negative read pos"); } catch (IllegalArgumentException expected) { }
+ }
+ Files.deleteIfExists(f);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void rejectsNullChannel() {
+ SeekableSink.over(null, 0);
+ }
+}
diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestParallelExecutor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestParallelExecutor.java
new file mode 100644
index 000000000..744034ddd
--- /dev/null
+++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestParallelExecutor.java
@@ -0,0 +1,286 @@
+/*
+ * 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.graph;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ForkJoinPool;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicIntegerArray;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class TestParallelExecutor {
+
+ // ---- over(...) argument validation ----
+
+ @Test(expected = NullPointerException.class)
+ public void overRejectsNullExecutor() {
+ ParallelExecutor.over(null, 1);
+ }
+
+ @Test
+ public void overRejectsNonPositiveParallelism() {
+ ExecutorService es = Executors.newSingleThreadExecutor();
+ try {
+ for (int bad : new int[]{0, -1}) {
+ try {
+ ParallelExecutor.over(es, bad);
+ fail("expected IllegalArgumentException for parallelism " + bad);
+ } catch (IllegalArgumentException expected) {
+ // ok
+ }
+ }
+ } finally {
+ es.shutdown();
+ }
+ }
+
+ // ---- completeness: every element exactly once ----
+
+ @Test
+ public void chunkingForEachIntCoversRangeExactlyOnce() {
+ ExecutorService es = Executors.newFixedThreadPool(3);
+ try {
+ ParallelExecutor pe = ParallelExecutor.over(es, 3);
+ AtomicIntegerArray hits = new AtomicIntegerArray(10_000);
+ pe.forEachInt(10_000, hits::incrementAndGet);
+ for (int i = 0; i < hits.length(); i++) {
+ assertEquals("index " + i, 1, hits.get(i));
+ }
+ // Zero-size range: no calls, no error.
+ AtomicInteger calls = new AtomicInteger();
+ pe.forEachInt(0, i -> calls.incrementAndGet());
+ assertEquals(0, calls.get());
+ } finally {
+ es.shutdown();
+ }
+ }
+
+ @Test
+ public void chunkingForEachIntStreamCoversAllElements() {
+ ExecutorService es = Executors.newFixedThreadPool(2);
+ try {
+ ParallelExecutor pe = ParallelExecutor.over(es, 2);
+ // A filtered (non-SIZED) stream exercises traversal-side batching.
+ SetChoosing a factory
+ * The provided implementations trade off differently; the caveats below are relative to each other:
+ *
+ *
+ * Common to all three: the calling thread blocks until the iteration settles, and the first
+ * observed body failure is rethrown to the caller; whether other elements still run after a
+ * failure differs per the caveats above.
+ */
+public interface ParallelExecutor {
+ /**
+ * Runs {@code body} for each {@code i} in {@code [0, upperBound)}, blocking until all complete.
+ *
+ * @param upperBound the exclusive upper bound of the index range (may be {@code 0})
+ * @param body the action to apply to each index
+ */
+ void forEachInt(int upperBound, IntConsumer body);
+
+ /**
+ * Runs {@code body} for each element produced by {@code source}, blocking until all complete.
+ * Callers pass a sequential stream; the implementation decides whether to parallelize it.
+ *
+ * @param source the (sequential) stream of primitive ints to iterate
+ * @param body the action to apply to each element
+ */
+ void forEach(IntStream source, IntConsumer body);
+
+ /**
+ * Runs {@code body} for each element produced by {@code source}, blocking until all complete.
+ * Callers pass a sequential stream; the implementation decides whether to parallelize it.
+ *
+ * @param source the (sequential) stream to iterate
+ * @param body the action to apply to each element
+ * @param {@code
+ * try (CompactionDestination.OutputReservation r = destination.reserve()) {
+ * // ...compactor writes the graph into r.file() at r.startOffset()...
+ * r.commit(bodyLength); // success: body written & durable; embedder finalizes its footer
+ * } // close() always runs; no commit() => reservation released, partial output discarded
+ * }
+ *
+ *