feat(vectordata): chunk-range residency and range prebuffer on storage

RangeFill reports what fetching a byte range would cost at chunk
granularity — resident chunks are free, and alignment overfetch is
visible before the fetch. prebufferRange drives only the covering
chunks; CacheStats carries the transfer-chunk size so consumers can map
byte offsets to chunk ordinals; rangeCapable distinguishes sources that
cannot service partial fetches.
This commit is contained in:
Jonathan Shook committed 2026-08-26 18:35:36 +00:00
1 parent 2af9dec9cc
commit b30d1d074e
6 files changed
+131 -4

No files matched your search

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 The NoSQLBench Authors.
*
* 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.nosqlbench.vectordata;
/// A half-open byte range `[start, end)` within a facet's payload.
public record ByteRange(long start, long end) {
/// Bytes spanned by this range.
public long length() { return Math.max(0, end - start); }
@Override public String toString() { return "[" + start + ".." + end + ")"; }
}
@@ -15,6 +15,9 @@
*/
package io.nosqlbench.vectordata;
/** Immutable observation of a reader's backing cache. */
public record CacheStats(AccessMode accessMode, long totalBytes, long cachedBytes,
/// Immutable observation of a reader's backing cache. `chunkSize` is
/// the transfer-chunk size in bytes — `0` for local storage, which has
/// no chunks — so consumers can map byte offsets to chunk ordinals
/// (e.g. for chunk-aligned sampling or prefetch).
public record CacheStats(AccessMode accessMode, long totalBytes, long cachedBytes, long chunkSize,
long hits, long misses, boolean complete) { }
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 The NoSQLBench Authors.
*
* 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.nosqlbench.vectordata;
/// What fetching a byte range would actually cost, at chunk
/// granularity. The unit of fetch is a chunk, not a byte, so a range's
/// real cost is rarely the range's length: a 4 KiB window against 8 MiB
/// chunks is 8 MiB, and a window whose chunks are already resident is
/// free. Both facts have to be visible *before* the fetch, or a caller
/// cannot tell an incremental warm-up from a full download.
///
/// `firstChunk` and `lastChunk` are inclusive; `alignedStart` and
/// `alignedEnd` are the byte range the fetch actually spans once
/// widened to chunk boundaries — always a superset of what was asked
/// for.
public record RangeFill(int firstChunk, int lastChunk, long chunkSize, int chunks, int chunksResident,
long alignedStart, long alignedEnd) {
/// Chunks that still have to be fetched.
public int chunksToFetch() { return Math.max(0, chunks - chunksResident); }
/// Bytes that will cross the network, at chunk granularity.
public long bytesToFetch() { return chunksToFetch() * chunkSize; }
/// Bytes fetched beyond the requested range because chunks are the
/// granularity. This is the number that tells a caller its
/// scattered single-record prefetches are really a full download.
public long overfetchBytes(long requestedStart, long requestedEnd) {
long requested = Math.max(0, requestedEnd - requestedStart);
long spanned = Math.max(0, alignedEnd - alignedStart);
return Math.max(0, spanned - requested);
}
/// Whether every chunk covering the range is already resident.
public boolean isResident() { return chunksResident >= chunks; }
}
@@ -17,6 +17,7 @@ package io.nosqlbench.vectordata.internal;
import io.nosqlbench.vectordata.CacheStats;
import io.nosqlbench.vectordata.PrebufferProgress;
import io.nosqlbench.vectordata.RangeFill;
import java.nio.ByteBuffer;
@@ -25,6 +26,20 @@ public interface ByteStorage extends AutoCloseable {
long size();
ByteBuffer read(long offset, int length);
void prebuffer(PrebufferProgress progress);
/// Drives only the chunks covering `[byteStart, byteEnd)` to
/// resident state, reporting bytes resident within the covered
/// span against the span's total. Local storage reports the span
/// as immediately complete.
void prebufferRange(long byteStart, long byteEnd, PrebufferProgress progress);
/// Chunk-level residency for a byte range, or `null` when the
/// storage has no chunks — local storage is always fully resident
/// and a range question about it has no meaning beyond "yes".
/// Callers should treat `null` as "free".
RangeFill rangeFill(long byteStart, long byteEnd);
/// Whether a sub-range of this storage can be fetched without
/// fetching the rest. Local storage is trivially range-capable; a
/// remote source that cannot service byte ranges is not.
boolean rangeCapable();
boolean isComplete();
CacheStats stats();
@Override default void close() { }
@@ -18,6 +18,7 @@ package io.nosqlbench.vectordata.internal;
import io.nosqlbench.vectordata.AccessMode;
import io.nosqlbench.vectordata.CacheStats;
import io.nosqlbench.vectordata.PrebufferProgress;
import io.nosqlbench.vectordata.RangeFill;
import io.nosqlbench.vectordata.VectorDataException;
import java.io.IOException;
@@ -54,7 +55,13 @@ public final class MappedStorage implements ByteStorage {
throw new VectorDataException("Byte range outside source: offset=" + offset + ", length=" + length + ", size=" + size);
}
@Override public void prebuffer(PrebufferProgress progress) { progress.onProgress(size, size); }
@Override public void prebufferRange(long byteStart, long byteEnd, PrebufferProgress progress) {
long length = Math.max(0, Math.min(byteEnd, size) - byteStart);
progress.onProgress(length, length);
}
@Override public RangeFill rangeFill(long byteStart, long byteEnd) { return null; }
@Override public boolean rangeCapable() { return true; }
@Override public boolean isComplete() { return true; }
@Override public CacheStats stats() { return new CacheStats(AccessMode.LOCAL, size, size, 0, 0, true); }
@Override public CacheStats stats() { return new CacheStats(AccessMode.LOCAL, size, size, 0, 0, 0, true); }
@Override public void close() { try { channel.close(); } catch (IOException ignored) { } }
}
@@ -19,6 +19,7 @@ import io.nosqlbench.vectordata.AccessMode;
import io.nosqlbench.vectordata.CacheStats;
import io.nosqlbench.vectordata.IntegrityException;
import io.nosqlbench.vectordata.PrebufferProgress;
import io.nosqlbench.vectordata.RangeFill;
import io.nosqlbench.vectordata.VectorDataException;
import io.nosqlbench.vectordata.VectorDataSettings;
@@ -149,11 +150,40 @@ public final class RemoteStorage implements ByteStorage {
@Override public void prebuffer(PrebufferProgress progress) {
for (int i = 0; i < chunks(); i++) { ensure(i, i); progress.onProgress(Math.min(size, (long) present.cardinality() * chunkSize), size); }
}
@Override public void prebufferRange(long byteStart, long byteEnd, PrebufferProgress progress) {
long end = Math.min(byteEnd, size);
if (byteStart < 0 || byteStart >= end) { progress.onProgress(0, 0); return; }
int first = Math.toIntExact(byteStart / chunkSize);
int last = Math.toIntExact((end - 1) / chunkSize);
long rangeBytes = Math.min(size, (last + 1L) * chunkSize) - (long) first * chunkSize;
synchronized (lock) {
long done = 0;
for (int chunk = first; chunk <= last; chunk++) {
if (present.get(chunk)) hits.incrementAndGet(); else fetch(chunk);
done += chunkLength(chunk);
progress.onProgress(done, rangeBytes);
}
}
}
@Override public RangeFill rangeFill(long byteStart, long byteEnd) {
if (size == 0 || byteEnd <= byteStart || byteStart < 0 || byteStart >= size) return null;
long end = Math.min(byteEnd, size);
int first = Math.toIntExact(byteStart / chunkSize);
int last = Math.toIntExact(Math.min((end - 1) / chunkSize, chunks() - 1L));
if (first > last) return null;
int resident = 0;
synchronized (lock) { for (int chunk = first; chunk <= last; chunk++) if (present.get(chunk)) resident++; }
long alignedStart = (long) first * chunkSize;
long alignedEnd = Math.min((last + 1L) * chunkSize, size);
return new RangeFill(first, last, chunkSize, last - first + 1, resident, alignedStart, alignedEnd);
}
@Override public boolean rangeCapable() { return ranges; }
private long chunkLength(int chunk) { return Math.min(chunkSize, size - (long) chunk * chunkSize); }
@Override public boolean isComplete() { return complete; }
@Override public CacheStats stats() {
long cached = Math.min(size, (long) present.cardinality() * chunkSize);
AccessMode mode = reference == null ? (ranges ? AccessMode.MERKLE_CHUNKED : AccessMode.FULL_TRANSFER) : AccessMode.MERKLE_HASHED;
return new CacheStats(mode, size, cached, hits.get(), misses.get(), complete);
return new CacheStats(mode, size, cached, chunkSize, hits.get(), misses.get(), complete);
}
private int chunks() { return size == 0 ? 0 : Math.toIntExact((size + chunkSize - 1) / chunkSize); }
private void checkBounds(long offset, int length) {