diff --git a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/ByteRange.java b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/ByteRange.java new file mode 100644 index 000000000..e1bfd717c --- /dev/null +++ b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/ByteRange.java @@ -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 + ")"; } +} diff --git a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/CacheStats.java b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/CacheStats.java index d6ef37e12..9a3bd7124 100644 --- a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/CacheStats.java +++ b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/CacheStats.java @@ -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) { } diff --git a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/RangeFill.java b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/RangeFill.java new file mode 100644 index 000000000..ba9d2d637 --- /dev/null +++ b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/RangeFill.java @@ -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; } +} diff --git a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/ByteStorage.java b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/ByteStorage.java index 8fba6ad3f..06c939e6d 100644 --- a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/ByteStorage.java +++ b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/ByteStorage.java @@ -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() { } diff --git a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/MappedStorage.java b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/MappedStorage.java index 792288842..d81febec1 100644 --- a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/MappedStorage.java +++ b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/MappedStorage.java @@ -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) { } } } diff --git a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/RemoteStorage.java b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/RemoteStorage.java index 5ec3a8ff1..29f39f7a2 100644 --- a/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/RemoteStorage.java +++ b/nb-vectordata/src/main/java/io/nosqlbench/vectordata/internal/RemoteStorage.java @@ -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) {