Skip to content

Integration robustness contracts - #710

Open
jshook wants to merge 5 commits into
mainfrom
integration-robustness
Open

Integration robustness contracts#710
jshook wants to merge 5 commits into
mainfrom
integration-robustness

Conversation

@jshook

@jshook jshook commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Synopsis

This is a set of changes to make the embedding layer between JVector an hosting runtimes more robust, visible, and efficient. The aspects addressed here are those which we haven't been prescriptive enough about for embedding systems.
Most essentially, these contracts and supporting types and wrappers are used by embeding systems to instruct JVector where and how to do certain things, like writing index outputs, or using thread pools.

The aspects covered in this set include execution controls, rate-limiting, output vectoring (virtualization), runtime-safety checks on non-runtime debugging features, and memory safety.

Why

Jvector today assumes it owns its execution, its output files, and its process. When it runs embedded in a host that manages its own thread pools, IO budget, mmap lifecycle, and cancellation — e.g. Cassandra's SAI vector indexing driving compaction — those assumptions leak:

  • parallel work escapes to ForkJoinPool.commonPool() / PhysicalCoreExecutor.pool(), outside the host's budget;
  • long-running operations are opaque (no progress) and unthrottled (their write bandwidth ignores the host's throughput limiter);
  • output must be a jvector-owned file, so it can't be written into a slot inside a larger host container;
  • diagnostic-only work can't be switched off in production;
  • a host that unmaps a source underneath an in-flight read faults the JVM (SIGSEGV) instead of failing diagnosably.

This PR adds the extension points a host needs to close those gaps. It is purely additive and opt-in — no existing behavior changes, and nothing in jvector consumes these seams yet. It's the foundation an embedding/compaction effort builds on; a follow-up PR illustrates how the compactor's calling conventions convert onto these seams.

What's in it, in detail

Five contracts, one per commit:

  1. Cooperative work-limiting + progress (util/work/) — ProgressLimiter = ProgressTracker (progress up) + WorkLimiter (throttle down), with WorkStage for phase scoping and LeakyBucketLimiter as a default rate-limited limiter. A host installs one to observe and rate-limit a long jvector operation, and to checkpoint cancellation at phase boundaries.
  2. Pluggable output sink (disk/) — SeekableSink + CompactionDestination (with file-backed defaults) let a host redirect compaction output through a caller-owned channel — e.g. a slot inside a larger container after a reserved header — instead of jvector always allocating its own file. Target has an explicit open → commit → close lifecycle (no commit() ⇒ aborted, partial output discarded).
  3. ParallelExecutor (graph/) — a minimal parallel-for abstraction so a host supplies its own pool (forkJoin(pool)) or runs inline (callerRuns()), instead of jvector reaching for the common pool.
  4. RuntimeMode (util/) — a process-level jvector.mode gate. Unset is production; diagnostic-only work is opt-in behind it.
  5. Reader memory-safety (disk/) — bounded record reads in OnDiskGraphIndex (an out-of-range node id fails with IllegalArgumentException instead of becoming a wild offset into the mapped file), plus documenting the close() contract on ReaderSupplier/SimpleMappedReader (the raw-release-immediate-unmap vs. coordinated-liveness-handshake families) so a host knows not to close a supplier while any vended reader is live.

Design principles

  • Additive & opt-in. No existing public signature is removed or changed; deprecation of any older path is a separate concern.
  • Base module, --release 11. Everything lands in jvector-base (bytecode v55), so it runs on every supported JDK (11–25). No META-INF/versions/* overrides, no incubator Vector API use — no per-runtime divergence.
  • Seams only, no consumers. Nothing in jvector calls these yet, so the PR reviews in isolation and adoption can land separately.

Deliberately not here

No changes to the compactor or its algorithm, no new call sites, no host-specific code. Adoption is the follow-up PR.

Testing

jvector-base compiles at --release 11. New unit tests cover ProgressLimiter, SeekableSink, and RuntimeMode. The only edits to existing files are non-breaking (Javadoc on ReaderSupplier/SimpleMappedReader, an internal bounds check in OnDiskGraphIndex).

Stats: 17 files, +1346/−3 (the 3 deletions are lines replaced by the OnDiskGraphIndex bounds check).

Commits

  • util/work: cooperative work-limiting and progress interface
  • disk: pluggable output sink for compaction writes
  • graph: ParallelExecutor abstraction for host-provided execution
  • util: RuntimeMode gate for opt-in diagnostic work
  • disk: memory-safety guards for host-managed reads

jshook added 5 commits August 12, 2026 16:13
Introduce an opt-in control surface a host can install on long-running
jvector operations, without touching any existing call site:

  - WorkStage / WorkLimiter / ProgressTracker: the admission + reporting
    primitives (throttle down, progress up).
  - ProgressLimiter: the single combined facet a host implements.
  - LeakyBucketLimiter: a default rate-limited WorkLimiter.

Purely additive. Nothing in the library calls these yet; they establish
the seam that host-driven operations (e.g. compaction) can later be
rewritten against.
Add a host-supplied destination abstraction so compaction output can be
written through a caller-owned channel instead of always allocating its
own file:

  - SeekableSink / FileChannelSeekableSink: a minimal seekable byte sink
    and its file-backed implementation.
  - CompactionDestination / FileCompactionDestination: the compaction
    output target, resolvable to a SeekableSink.

Purely additive interface types; no existing code is wired to them on
this branch.
Add ParallelExecutor, a minimal parallel-for abstraction that lets an
embedding host supply its own execution strategy -- its own pool, or
caller-runs on the calling thread -- instead of jvector reaching for
ForkJoinPool.commonPool():

  - forkJoin(pool): run on a caller-supplied ForkJoinPool
  - callerRuns():   run inline, no work escaping to a shared pool
  - forEachInt / forEach: the parallel-for entry points

Additive: a standalone interface with no consumers on this branch. It
establishes the execution seam that build, quantization, and compaction
paths can be rewritten against later.
Add RuntimeMode, a process-level switch (jvector.mode) separating
production from diagnostic runs. Unset is production; diagnostic-only
work (e.g. verification walks) sits behind this gate and must be opted
into explicitly.

Additive: a standalone type with no call sites on this branch. It
establishes the gate that diagnostic code paths can consult once wired.
Harden the read path for embedding hosts that manage their own mmap
lifecycle, where a stale offset or a close racing an in-flight read
faults the JVM (SIGSEGV) instead of throwing:

  - OnDiskGraphIndex: bound record reads. A node id outside the graph
    would become a wild offset into the mapped file (garbage, or a
    fault); requireValidNode() rejects it up front, and a stale/corrupt
    neighbor block now fails with IllegalStateException instead of
    consuming garbage ints.
  - ReaderSupplier / SimpleMappedReader: document the close() contract --
    the raw-release (immediate unmap -> SIGSEGV) vs coordinated
    (liveness handshake -> IllegalStateException) families -- so a host
    knows a supplier must not be closed until every vended reader is
    quiescent.

Non-breaking: internal bounds checks and documentation only, no
signature or call-path changes. The compactor-side memory safety
(drain-on-unwind, truncate-reused-outputs) stays with the compaction
work, where it lives.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Before you submit for review:

  • Does your PR follow guidelines from CONTRIBUTIONS.md?
  • Did you summarize what this PR does clearly and concisely?
  • Did you include performance data for changes which may be performance impacting?
  • Did you include useful docs for any user-facing changes or features?
  • Did you include useful javadocs for developer oriented changes, explaining new concepts or key changes?
  • Did you rebase your branch onto the latest main for regression testing and PR submission?
  • Did you trigger regression testing via Run Bench Main and review results?
  • Did you adhere to the code formatting guidelines (TBD)
  • Did you group your changes for easy review, providing meaningful descriptions for each commit?
  • Did you ensure that all files contain the correct copyright header?
  • Did you add documentation for this feature to the release notes directory?

If you did not complete any of these, then please explain below.

@ashkrisk ashkrisk left a comment

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 PR adds in some helpful comments and checks (in OnDiskGraphIndex, ReaderSupplier, SimpleMappedReader) while adding some new interfaces. IMO those are different concerns: it might be better to split the comments and checks off into a separate PR while leaving the new interfaces here.

* 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.

Comment on lines +31 to +33
if (path == null) {
throw new NullPointerException("path");
}

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.

Suggested change
if (path == null) {
throw new NullPointerException("path");
}
Objects.requireNonNull(path);

Target open() throws IOException;

/** One compaction's output region plus its commit/abort lifecycle. */
interface Target 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.

Instead of passing a CompactionDestination on which open needs to be called to obtain a CompactionDestination.Target, couldn't a client simply pass in a CompactionTarget class directly?

* 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.
*/
public interface ParallelExecutor {

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 takes care of the forEach operations for generic Streams and IntStreams, but there are plenty of other terminal operations that are not expressed, for example collect and reduce for generic streams, count, min, max, toArray for primitive streams etc. There are whole other stream types like ByteStream which are not covered. Even if we don't use those terminal operations now, this interface locks us out of using any of those operations in the future.

In theory you could add all those operations to the ParallelExecutor, but that just leaves us with a huge amount of boilerplate to maintain.

The solution used elsewhere in JV is to accept a ForkJoinPool directly, run all parallel stream operations within that pool. This approach has certain downsides (for example, if you want to run a parallel task single-threaded, it'll probably run on the ForkJoin worker thread while the current thread is blocked and idle), but is rather flexible in terms of the operations that are supported.

Are the trade-offs involved in using this interface really worth it?

* non-decreasing within a stage
* @param total total work for this stage, or {@code -1} if not yet known
*/
void onProgress(WorkStage stage, long completed, long total);

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.

Perhaps the onProgress method should be defined for each PhaseScope rather than for each ProgressTracker? That way, implementers of this method don't need to worry about managing multiple progress bars, think about handling errors in case a WorkStage is submitted before it's PhaseScope is started etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants