fix: handle remote blob fetch request caps (#4309)

## Cause

Remote `fetch_blobs` sent every row ID in one Cloud request, exceeding
the server's 1024-row or 64 MiB byte cap. A single blob over the byte
cap could not be read through this method.

## Fix

- Send at most 1024 row IDs per request and split batches further when
the server reports the blob-byte cap, preserving input order and nulls.
- Resolve one exact dataset version before multi-request reads. If an
initial single request hits the byte cap, resolve the version before any
successful split or Range read; use that version for every resulting
request.
- Read an individual oversized blob through the existing Range route.
- Document the per-request caps and transparent handling in the Rust,
Python, and TypeScript APIs and generated JS docs.

## Validation

- Focused Rust `fetch_blobs` tests: 26 passed, including row-count,
byte-cap, Range fallback, and exact-version assertions.
- Rust clippy and formatting passed.
- Focused Python blob tests: 13 passed; Ruff lint and formatting passed.
- Node build, lint, docs generation, and focused `fetchBlobs` test
passed.

Fixes #4275

<!-- lance-gatekeeper-fix:v1 agent=6ae94d49bf1d4cae057bba72584e7b90
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
This commit is contained in:
lancedb-gatefixer[bot]
2026-09-28 20:31:36 -05:00
committed by GitHub
parent c119f0c8e5
commit 8e3606fbe3
6 changed files with 284 additions and 5 deletions
+4
View File
@@ -548,6 +548,10 @@ Bytes for `column` at row IDs from [Query.withRowId](Query.md#withrowid).
Reads the table's current checkout. IDs from another version can fail after
compaction unless stable row ids are enabled. Results keep input order and
duplicates. Null blobs are `null`. Empty blobs are empty buffers.
Remote servers limit each request to 1024 row IDs and 64 MiB of blob bytes.
The client splits requests automatically and reads an individual larger
blob through the Range route. This method still materializes all bytes in
memory; use [Table.fetchBlobFiles](Table.md#fetchblobfiles) for large values.
#### Parameters
+4
View File
@@ -537,6 +537,10 @@ export abstract class Table {
* Reads the table's current checkout. IDs from another version can fail after
* compaction unless stable row ids are enabled. Results keep input order and
* duplicates. Null blobs are `null`. Empty blobs are empty buffers.
* Remote servers limit each request to 1024 row IDs and 64 MiB of blob bytes.
* The client splits requests automatically and reads an individual larger
* blob through the Range route. This method still materializes all bytes in
* memory; use {@link Table.fetchBlobFiles} for large values.
*/
abstract fetchBlobs(
column: string,
+4 -2
View File
@@ -1963,8 +1963,10 @@ class Table(ABC):
``_rowid`` values stay valid after compaction when the table has stable
row ids.
Convenience for small payloads. For large values use
:meth:`fetch_blob_files`.
Remote servers limit each request to 1024 row IDs and 64 MiB of blob
bytes. The client splits requests automatically and reads an individual
larger blob through the Range route. This method still materializes all
bytes in memory; for large values use :meth:`fetch_blob_files`.
"""
@abstractmethod
+186
View File
@@ -5187,6 +5187,192 @@ mod tests {
assert_eq!(blobs.value(2), b"gamma");
}
#[tokio::test]
async fn test_fetch_blobs_splits_row_ids_at_one_version_and_preserves_order() {
let request_sizes = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = request_sizes.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| {
if request.url().path() == "/v1/table/my_table/describe/" {
return http::Response::builder()
.status(200)
.body(br#"{"version":7,"schema":{"fields":[]}}"#.to_vec())
.unwrap();
}
assert_eq!(request.url().path(), "/v1/table/my_table/fetch_blobs/");
let body = request_body_json(&request);
assert_eq!(body["version"], 7);
let ids = body["row_ids"].as_array().unwrap();
seen.lock().unwrap().push(ids.len());
if ids.len() > 1024 {
return http::Response::builder()
.status(400)
.body(b"fetch_blobs accepts at most 1024 row IDs".to_vec())
.unwrap();
}
let mut builder = LargeBinaryBuilder::new();
for id in ids {
let id = id.as_u64().unwrap();
if id == 1023 {
builder.append_null();
} else {
builder.append_value(id.to_string().as_bytes());
}
}
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"image",
DataType::LargeBinary,
true,
)])),
vec![Arc::new(builder.finish())],
)
.unwrap();
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(write_ipc_stream_uncompressed(&batch))
.unwrap()
},
);
let ids: Vec<u64> = (0..1024).chain([42]).collect();
let blobs = table.fetch_blobs("image", &ids).await.unwrap();
assert_eq!(blobs.len(), 1025);
assert_eq!(blobs.value(0), b"0");
assert_eq!(blobs.value(1022), b"1022");
assert!(blobs.is_null(1023));
assert_eq!(blobs.value(1024), b"42");
assert_eq!(request_sizes.lock().unwrap().as_slice(), &[1024, 1]);
}
#[tokio::test]
async fn test_fetch_blobs_splits_byte_limited_requests_and_reads_large_blob_by_range() {
// Simulate a lower byte cap so this test exercises the same 400 response
// without allocating 64 MiB of blob data.
let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = requests.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
return http::Response::builder()
.status(200)
.body(br#"{"version":42,"schema":{"fields":[]}}"#.to_vec())
.unwrap();
}
if path == "/v1/table/my_table/fetch_blobs/" {
let body = request_body_json(&request);
let ids = body["row_ids"].as_array().unwrap();
if body["version"].is_null() {
// Only the initial failed request may read live latest.
assert_eq!(ids.len(), 5);
} else {
assert_eq!(body["version"], 42);
}
seen.lock().unwrap().push(format!("POST {}", ids.len()));
let mut builder = LargeBinaryBuilder::new();
let mut total_bytes = 0;
for id in ids {
let value: Option<&[u8]> = match id.as_u64().unwrap() {
10 => Some(b"aaaa"),
20 => Some(b"bbb"),
30 => None,
40 => Some(b"0123456789"),
id => panic!("unexpected row id {id}"),
};
if let Some(value) = value {
total_bytes += value.len();
builder.append_value(value);
} else {
builder.append_null();
}
}
if total_bytes > 6 {
return http::Response::builder()
.status(400)
.body(br#"{"error":"Bad request: fetch_blobs accepts at most 67108864 total blob bytes"}"#.to_vec())
.unwrap();
}
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"image",
DataType::LargeBinary,
true,
)])),
vec![Arc::new(builder.finish())],
)
.unwrap();
return http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(write_ipc_stream_uncompressed(&batch))
.unwrap();
}
assert_eq!(path, "/v1/table/my_table/blob/image/40/bytes");
assert!(request.url().query().unwrap().contains("version=42"));
let range = request
.headers()
.get(reqwest::header::RANGE)
.unwrap()
.to_str()
.unwrap();
seen.lock().unwrap().push(format!("GET {range}"));
match range {
"bytes=0-0" => http::Response::builder()
.status(206)
.header(reqwest::header::CONTENT_RANGE, "bytes 0-0/10")
.body(b"0".to_vec())
.unwrap(),
"bytes=0-" => http::Response::builder()
.status(206)
.header(reqwest::header::CONTENT_RANGE, "bytes 0-9/10")
.body(b"0123456789".to_vec())
.unwrap(),
_ => panic!("unexpected range: {range}"),
}
},
);
let blobs = table
.fetch_blobs("image", &[10, 20, 30, 40, 20])
.await
.unwrap();
assert_eq!(blobs.len(), 5);
assert_eq!(blobs.value(0), b"aaaa");
assert_eq!(blobs.value(1), b"bbb");
assert!(blobs.is_null(2));
assert_eq!(blobs.value(3), b"0123456789");
assert_eq!(blobs.value(4), b"bbb");
let requests = requests.lock().unwrap();
assert!(requests.contains(&"GET bytes=0-0".to_string()));
assert!(requests.contains(&"GET bytes=0-".to_string()));
}
#[tokio::test]
async fn test_fetch_blobs_does_not_split_unrelated_bad_requests() {
let requests = Arc::new(AtomicUsize::new(0));
let seen = requests.clone();
let table =
Table::new_with_handler_version("my_table", semver::Version::new(0, 5, 0), move |_| {
seen.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(400)
.body(b"unknown blob column".to_vec())
.unwrap()
});
assert_fetch_blobs_http_error(
table.fetch_blobs("missing", &[10, 20]).await.unwrap_err(),
"unknown blob column",
);
assert_eq!(requests.load(Ordering::SeqCst), 1);
}
fn table_with_fetch_blobs_response(body: Vec<u8>) -> Table {
table_with_fetch_blobs_content_type(Some(ARROW_STREAM_CONTENT_TYPE), body)
}
+82 -2
View File
@@ -21,7 +21,22 @@ use crate::error::Result;
use crate::remote::client::{HttpSend, RequestResultExt, RestfulLanceDbClient};
use crate::table::BaseTable;
use super::{FreshnessHeaders, FreshnessState, RemoteTable, freshness_headers_snapshot};
use super::{
FreshnessHeaders, FreshnessState, ReadSnapshot, RemoteTable, freshness_headers_snapshot,
};
// The Cloud route rejects larger row-id lists. Its separate 64 MiB byte limit
// is handled by splitting a rejected request below.
const MAX_FETCH_BLOBS_ROW_IDS: usize = 1024;
fn is_fetch_blobs_byte_limit_error(error: &Error) -> bool {
matches!(error, Error::Http {
source,
status_code: Some(StatusCode::BAD_REQUEST),
..
} if source.to_string().contains("fetch_blobs accepts at most")
&& source.to_string().contains("total blob bytes"))
}
#[derive(Debug, Clone, Copy)]
enum RangeRequestMode {
@@ -369,7 +384,62 @@ impl<S: HttpSend> RemoteTable<S> {
message: "fetch_blobs is not supported on this LanceDB Cloud server".into(),
});
}
let read_snapshot = self.snapshot_read_state().await;
let mut read_snapshot = self.snapshot_read_state().await;
// A selection spanning requests must use one exact dataset version.
// Resolve latest before the first chunk; a checked-out version is exact already.
if row_ids.len() > MAX_FETCH_BLOBS_ROW_IDS && read_snapshot.version.is_none() {
read_snapshot.version = Some(self.describe_read_snapshot(read_snapshot).await?.version);
}
let mut pending: Vec<&[u64]> = row_ids.chunks(MAX_FETCH_BLOBS_ROW_IDS).rev().collect();
let mut chunks = Vec::new();
while let Some(ids) = pending.pop() {
match self.fetch_blobs_chunk(column, ids, read_snapshot).await {
Ok(blobs) => chunks.push(blobs),
Err(error) if is_fetch_blobs_byte_limit_error(&error) => {
// A single-request call may turn into several requests after a
// byte-cap error. No bytes from the failed request were used.
if read_snapshot.version.is_none() {
read_snapshot.version =
Some(self.describe_read_snapshot(read_snapshot).await?.version);
}
if ids.len() == 1 {
// The whole-byte route cannot serve this blob. The Range route
// has no aggregate response limit and preserves null alignment.
let mut files = self
.fetch_blob_files_with_snapshot(column, ids, read_snapshot)
.await?;
let blob = match files.pop().unwrap() {
Some(file) => Some(file.read().await?),
None => None,
};
chunks.push(LargeBinaryArray::from(vec![blob.as_deref()]));
} else {
let mid = ids.len() / 2;
pending.push(&ids[mid..]);
pending.push(&ids[..mid]);
}
}
Err(error) => return Err(error),
}
}
if chunks.len() == 1 {
return Ok(chunks.pop().unwrap());
}
let chunk_refs: Vec<&dyn Array> = chunks.iter().map(|chunk| chunk as &dyn Array).collect();
Ok(arrow::compute::concat(&chunk_refs)?
.as_any()
.downcast_ref::<LargeBinaryArray>()
.expect("concatenating LargeBinary arrays returns LargeBinary")
.clone())
}
async fn fetch_blobs_chunk(
&self,
column: &str,
row_ids: &[u64],
read_snapshot: ReadSnapshot,
) -> Result<LargeBinaryArray> {
let mut body = serde_json::json!({
"version": read_snapshot.version,
"column": column,
@@ -460,6 +530,16 @@ impl<S: HttpSend> RemoteTable<S> {
}
let read_snapshot = self.snapshot_read_state().await;
self.fetch_blob_files_with_snapshot(column, row_ids, read_snapshot)
.await
}
async fn fetch_blob_files_with_snapshot(
&self,
column: &str,
row_ids: &[u64],
read_snapshot: ReadSnapshot,
) -> Result<Vec<Option<BlobFile>>> {
let encoded_column = urlencoding::encode(column);
let requesters = row_ids
.iter()
+4 -1
View File
@@ -1251,7 +1251,10 @@ impl Table {
/// Materialize blob bytes for the given row ids.
///
/// Output matches `row_ids` in length and order. Null blobs are null;
/// valid empty blobs contain empty byte strings. Prefer
/// valid empty blobs contain empty byte strings. Cloud limits individual
/// requests to 1024 row ids and 64 MiB of blob bytes; the remote client
/// splits requests and reads a single larger blob through the Range route.
/// This method still materializes all bytes in memory, so prefer
/// [`Self::fetch_blob_files`] for large selections.
///
/// `_rowid` values stay valid after compaction when the table has stable