mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-14 14:46:53 +00:00
feat: support batched blob range reads
This commit is contained in:
@@ -297,6 +297,11 @@ class Table:
|
||||
async def fetch_blobs(
|
||||
self, column: str, row_ids: list[int]
|
||||
) -> pa.LargeBinaryArray: ...
|
||||
async def fetch_blob_ranges(
|
||||
self,
|
||||
column: str,
|
||||
requests: List[Tuple[int, int, int]],
|
||||
) -> pa.LargeBinaryArray: ...
|
||||
async def fetch_blob_files(
|
||||
self, column: str, row_ids: list[int]
|
||||
) -> list[Optional[BlobFile]]: ...
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class RemoteTable(Table):
|
||||
def fetch_blobs(self, column: str, row_ids) -> pa.LargeBinaryArray:
|
||||
raise NotImplementedError("fetch_blobs() is not supported on LanceDB Cloud")
|
||||
|
||||
def fetch_blob_ranges(self, column: str, requests) -> pa.LargeBinaryArray:
|
||||
raise NotImplementedError(
|
||||
"fetch_blob_ranges() is not supported on LanceDB Cloud"
|
||||
)
|
||||
|
||||
def fetch_blob_files(self, column: str, row_ids):
|
||||
raise NotImplementedError(
|
||||
"fetch_blob_files() is not supported on LanceDB Cloud"
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import (
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
overload,
|
||||
@@ -1537,6 +1538,23 @@ class Table(ABC):
|
||||
:meth:`fetch_blob_files`.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def fetch_blob_ranges(
|
||||
self,
|
||||
column: str,
|
||||
requests: Sequence[Tuple[int, int, int]],
|
||||
) -> pa.LargeBinaryArray:
|
||||
"""Materialize row-specific byte ranges from a blob v2 column.
|
||||
|
||||
Each request is a ``(row_id, offset, length)`` tuple. Requests may be
|
||||
repeated or reordered, including multiple ranges for the same blob.
|
||||
The result has the same length and order as ``requests``; null blobs
|
||||
produce null slots and empty ranges on non-null blobs produce ``b""``.
|
||||
|
||||
Row IDs can be obtained from a query with ``with_row_id(True)``. This
|
||||
API is currently supported only by local tables.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def fetch_blob_files(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
@@ -2260,6 +2278,13 @@ class LanceTable(Table):
|
||||
) -> pa.LargeBinaryArray:
|
||||
return LOOP.run(self._table.fetch_blobs(column, row_ids))
|
||||
|
||||
def fetch_blob_ranges(
|
||||
self,
|
||||
column: str,
|
||||
requests: Sequence[Tuple[int, int, int]],
|
||||
) -> pa.LargeBinaryArray:
|
||||
return LOOP.run(self._table.fetch_blob_ranges(column, list(requests)))
|
||||
|
||||
def fetch_blob_files(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
) -> "list[Optional[BlobFile]]":
|
||||
@@ -5823,6 +5848,13 @@ class AsyncTable:
|
||||
column, _normalize_blob_row_ids(row_ids, column)
|
||||
)
|
||||
|
||||
async def fetch_blob_ranges(
|
||||
self,
|
||||
column: str,
|
||||
requests: Sequence[Tuple[int, int, int]],
|
||||
) -> pa.LargeBinaryArray:
|
||||
return await self._inner.fetch_blob_ranges(column, list(requests))
|
||||
|
||||
async def fetch_blob_files(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
) -> "list[Optional[BlobFile]]":
|
||||
|
||||
@@ -198,6 +198,58 @@ def test_fetch_blobs_null_alignment():
|
||||
assert blobs[2].as_py() == b"present"
|
||||
|
||||
|
||||
def test_fetch_blob_ranges_aligns_repeated_ranges_and_nulls():
|
||||
table = _blob_table(
|
||||
"range_alignment",
|
||||
[{"id": 1, "image": b"abcdefghij"}, {"id": 2, "image": None}],
|
||||
)
|
||||
by_id = _row_ids_by_id(table)
|
||||
requests = [
|
||||
(by_id[1], 2, 3),
|
||||
(by_id[2], 0, 0),
|
||||
(by_id[1], 0, 2),
|
||||
(by_id[1], 2, 3),
|
||||
(by_id[1], 10, 0),
|
||||
]
|
||||
|
||||
ranges = table.fetch_blob_ranges("image", requests)
|
||||
|
||||
assert ranges.to_pylist() == [b"cde", None, b"ab", b"cde", b""]
|
||||
|
||||
|
||||
def test_fetch_blob_ranges_validates_requests():
|
||||
table = _blob_table("range_validation", [{"id": 1, "image": b"abc"}])
|
||||
row_id = _row_ids_by_id(table)[1]
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds blob size"):
|
||||
table.fetch_blob_ranges("image", [(row_id, 2, 2)])
|
||||
|
||||
with pytest.raises(ValueError, match="offset \\+ length overflowed"):
|
||||
table.fetch_blob_ranges("image", [(row_id, 2**64 - 1, 1)])
|
||||
|
||||
with pytest.raises(ValueError, match="row ids"):
|
||||
table.fetch_blob_ranges("image", [(2**64 - 1, 0, 1)])
|
||||
|
||||
|
||||
def test_fetch_blob_ranges_empty_requests_returns_empty_array():
|
||||
table = _blob_table("range_empty", [{"id": 1, "image": b"x"}])
|
||||
assert table.fetch_blob_ranges("image", []).to_pylist() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_fetch_blob_ranges():
|
||||
db = await lancedb.connect_async("memory:///")
|
||||
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
|
||||
table = await db.create_table("range_async", schema=schema)
|
||||
await table.add([{"id": 1, "image": b"abcdefghij"}])
|
||||
hits = await table.query().with_row_id().to_arrow()
|
||||
row_id = hits["_rowid"][0].as_py()
|
||||
|
||||
ranges = await table.fetch_blob_ranges("image", [(row_id, 1, 3), (row_id, 6, 2)])
|
||||
|
||||
assert ranges.to_pylist() == [b"bcd", b"gh"]
|
||||
|
||||
|
||||
def test_fetch_blobs_nested_path():
|
||||
db = lancedb.connect("memory:///")
|
||||
info = pa.StructArray.from_arrays(
|
||||
|
||||
+22
-1
@@ -17,7 +17,7 @@ use arrow::{
|
||||
ffi_stream::ArrowArrayStreamReader,
|
||||
pyarrow::{FromPyArrow, PyArrowType, ToPyArrow},
|
||||
};
|
||||
use lancedb::blob::BlobFile;
|
||||
use lancedb::blob::{BlobFile, BlobRangeRequest};
|
||||
use lancedb::index::scalar::FtsIndexBuilder;
|
||||
use lancedb::table::{
|
||||
AddDataMode, ColumnAlteration, Duration, FieldMetadataUpdate, FtsToken as LanceDbFtsToken,
|
||||
@@ -1101,6 +1101,27 @@ impl Table {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read row-specific blob-local byte ranges in one planned operation.
|
||||
#[pyo3(signature = (column, requests))]
|
||||
pub fn fetch_blob_ranges(
|
||||
self_: PyRef<'_, Self>,
|
||||
column: String,
|
||||
requests: Vec<(u64, u64, u64)>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.inner_ref()?.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let requests = requests
|
||||
.into_iter()
|
||||
.map(|(row_id, offset, length)| BlobRangeRequest::new(row_id, offset, length))
|
||||
.collect::<Vec<_>>();
|
||||
let blobs: LargeBinaryArray = inner
|
||||
.fetch_blob_ranges(column, requests)
|
||||
.await
|
||||
.infer_error()?;
|
||||
Python::attach(|py| blobs.to_data().to_pyarrow(py).map(|obj| obj.unbind()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Open lazy blob handles for `row_ids` from blob v2 column `column`.
|
||||
#[pyo3(signature = (column, row_ids))]
|
||||
pub fn fetch_blob_files(
|
||||
|
||||
Reference in New Issue
Block a user